Skip to content

Instantly share code, notes, and snippets.

@AliBakerSartawi
Last active August 5, 2024 09:50
Show Gist options
  • Save AliBakerSartawi/c4852279e541904881f45ef4912e33f2 to your computer and use it in GitHub Desktop.
Save AliBakerSartawi/c4852279e541904881f45ef4912e33f2 to your computer and use it in GitHub Desktop.
OTEL Attribute Decorator
import { ATTR_METADATA_KEY } from './constants';
import { AttrKey, AttrMetadata } from './types';
/**
* Sets a custom attribute key on a parameter so that it can be picked up
* by the Span decorator and mapped to the args values.
*/
export function Attr(key: AttrKey): ParameterDecorator {
return function (
target: NonNullable<unknown>,
propertyKey: string | symbol | undefined, // method name
parameterIndex: number,
) {
if (!propertyKey) {
console.log('propertyKey is undefined');
return;
}
const existingMetadata: AttrMetadata[] =
Reflect.getMetadata(ATTR_METADATA_KEY, target, propertyKey) || [];
const metadata: AttrMetadata = {
type: 'attr',
key,
parameterIndex,
};
const newMetadata = [...existingMetadata, metadata];
Reflect.defineMetadata(ATTR_METADATA_KEY, newMetadata, target, propertyKey);
};
}
export const ATTR_METADATA_KEY = Symbol('span:attr');
import { Logger } from '@nestjs/common';
import { recordSpanExceptionFactory } from './utils';
@Injectable()
export class ExampleService {
private readonly logger = new Logger(ExampleService.name);
private readonly recordException = recordSpanExceptionFactory(this.logger);
@Span()
async whatchamacallit(
@Attr('company.id') companyId: number,
@Attr('data') data: SomeDataDto,
) {
try {
/* Your awesome code in here */
} catch (e) {
this.recordException(`Failed to do this and that: ${e}`);
}
}
}
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { Resource } from '@opentelemetry/resources';
import { NodeSDK } from '@opentelemetry/sdk-node';
import {
AlwaysOnSampler,
BatchSpanProcessor,
NodeTracerProvider,
} from '@opentelemetry/sdk-trace-node';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import process from 'process';
class Tracer {
private sdk: NodeSDK | null = null;
private exporter = new OTLPTraceExporter();
private provider = new NodeTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'qr-messenger',
}),
});
public init() {
try {
// export spans to console (useful for debugging)
// if (process.env.ENV === 'development') {
// this.provider.addSpanProcessor(
// new BatchSpanProcessor(new ConsoleSpanExporter()),
// );
// }
// export spans to opentelemetry collector
this.provider.addSpanProcessor(new BatchSpanProcessor(this.exporter));
this.provider.register();
this.sdk = new NodeSDK({
traceExporter: this.exporter,
instrumentations: [
// getNodeAutoInstrumentations(),
// new HttpInstrumentation(),
// new ExpressInstrumentation(),
new NestInstrumentation(),
],
sampler: new AlwaysOnSampler(),
});
this.sdk.start();
console.info('The tracer has been initialized');
} catch (e) {
console.error('Failed to initialize the tracer', e);
} finally {
// gracefully shut down the SDK on process exit
process.on('SIGTERM', () => {
this.sdk
?.shutdown()
.then(
() => console.log('SDK shut down successfully'),
(err) => console.log('Error shutting down SDK', err),
)
.finally(() => process.exit(0));
});
}
}
}
new Tracer().init();
// tracer should be initialized before even importing anything from Nest
import './init-tracer';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomLogger, getPort } from './shared/utils/bootstrap.utils';
import { trace } from '@opentelemetry/api';
import { recordException } from './shared/utils/tracing.utils';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: new CustomLogger(),
});
/* -------------------------- Global Error Handling ------------------------- */
/**
* Necessary to catch an error that is thrown on `disconnected` event
* when a business manually logs out from a session that is currently active in the server
* or when puppeteer cannot find the chromium executable
*/
process.on('unhandledRejection', (reason) => {
const tracer = trace.getTracer('default');
tracer.startActiveSpan('unhandledRejection', (span) => {
recordException(span, reason);
// use console, not the custom logger as it strips away the stack trace
console.error('Unhandled Rejection:', reason);
span.end();
});
});
/* ------------------------------- Validation ------------------------------- */
app.useGlobalPipes(new ValidationPipe());
/* ------------------------- Global Exception Filter ------------------------ */
// app.useGlobalFilters(new GlobalExceptionFilter());
/* ---------------------------------- CORS ---------------------------------- */
app.enableCors();
/* ---------------------------------- PORT ---------------------------------- */
const PORT = getPort();
await app.listen(PORT);
}
void bootstrap();
import { SpanOptions, trace } from '@opentelemetry/api';
import {
copyMetadataFromFunctionToFunction,
recordException,
setAttribute,
setParameterAttributes,
} from './utils';
interface ExtendedSpanOptions extends SpanOptions {
name?: string;
ignoreOutcomeAttr?: boolean;
}
/**
* A modified version of the Span decorator from https://github.com/pragmaticivan/nestjs-otel
*/
export function Span(options: ExtendedSpanOptions = {}) {
return (
target: NonNullable<unknown>,
propertyKey: string,
propertyDescriptor: PropertyDescriptor,
) => {
const originalFunction = propertyDescriptor.value;
const wrappedFunction = function PropertyDescriptor(
this: NonNullable<unknown>,
...args: unknown[]
) {
const tracer = trace.getTracer('default');
const spanName =
options.name || `${target.constructor.name}.${propertyKey}`;
return tracer.startActiveSpan(spanName, options, (span) => {
setParameterAttributes(span, target, propertyKey, args);
if (originalFunction.constructor.name === 'AsyncFunction') {
return originalFunction
.apply(this, args)
.then((result: unknown) => {
if (!options.ignoreOutcomeAttr) {
setAttribute(propertyKey, span, 'outcome', result);
}
return result;
})
.catch((error: unknown) => {
recordException(span, error);
// Throw error to propagate it further
throw error;
})
.finally(() => {
span.end();
});
}
try {
const result = originalFunction.apply(this, args);
if (!options.ignoreOutcomeAttr) {
setAttribute(propertyKey, span, 'outcome', result);
}
return result;
} catch (error) {
recordException(span, error);
// Throw error to propagate it further
throw error;
} finally {
span.end();
}
});
};
// eslint-disable-next-line no-param-reassign
propertyDescriptor.value = wrappedFunction;
copyMetadataFromFunctionToFunction(originalFunction, wrappedFunction);
};
}
export interface AttrMetadata {
type: 'attr';
key: AttrKey;
parameterIndex: number;
}
export type AttrKey =
/* ---------------------- Frequent ---------------------- */
| 'outcome'
| 'company.id'
/* ----------------------- PubSub ----------------------- */
| 'pubsub.event'
/* ------------------------ Redis ----------------------- */
| 'redis.key'
| 'redis.value'
| 'redis.ttl.seconds'
| 'redis.score'
| 'redis.min.score'
| 'redis.max.score'
/* ----------------------- General ---------------------- */
| 'str'
| 'data'
| 'reason';
import { Span, SpanStatusCode } from '@opentelemetry/api';
import { AttrKey, AttrMetadata } from './types';
import { ATTR_METADATA_KEY } from './constants';
import { Logger } from '@nestjs/common';
export function recordException(span: Span, error: unknown) {
try {
const e = error instanceof Error ? error : new Error(JSON.stringify(error));
span.recordException(e);
span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
} catch (e) {
console.error(`Failed to record exception. Error: ${e}`);
}
}
function recordSpanException(error: unknown) {
try {
const span = trace.getActiveSpan();
if (span) {
recordException(span, error);
} else {
console.error('No active span found to record the exception.');
}
} catch (e) {
console.error(`Failed to record span exception. Error: ${e}`);
}
}
export function recordSpanExceptionFactory(logger: Logger) {
return function recordException(e: unknown) {
try {
recordSpanException(e);
logger.error(e);
} catch (e) {
logger.error(`Failed to record exception ${e}`);
}
};
}
export function getAttrs(
target: NonNullable<unknown>,
propertyKey: string,
): AttrMetadata[] {
try {
const metadata: AttrMetadata[] =
Reflect.getMetadata(ATTR_METADATA_KEY, target, propertyKey) || [];
const filteredMetadata = metadata.filter((item) => item.type === 'attr');
return filteredMetadata;
} catch (error) {
console.log({ error });
return [];
}
}
export function setAttribute(
propertyKey: string,
span: Span,
key: AttrKey,
value: unknown,
) {
const serialized = (() => {
try {
if (
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
) {
return value;
} else {
// circular objects will fail to stringify
return JSON.stringify(value) || 'null';
}
} catch (e) {
const error =
typeof e === 'object' && e !== null && 'message' in e ? e.message : e;
console.error(
`Failed to stringify value for span ${propertyKey}. Error ${error}`,
);
return 'un-json-stringify-able';
}
})();
try {
span.setAttribute(key, serialized);
} catch (error) {
console.error(error);
}
}
export function setParameterAttributes(
span: Span,
targetClass: NonNullable<unknown>,
propertyKey: string,
args: unknown[],
) {
try {
const attrs = getAttrs(targetClass, propertyKey);
attrs.forEach((attr) => {
const value = args?.[attr.parameterIndex];
setAttribute(propertyKey, span, attr.key, value);
});
} catch (error) {
console.error(error);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment