Ver Fonte

feat(telemetry-plugin): Refine and document telemetry-plugin APIs

Michael Bromley há 8 meses atrás
pai
commit
0f7ae934f8

+ 6 - 0
docs/sidebars.js

@@ -290,6 +290,12 @@ const sidebars = {
                     link: { type: 'doc', id: 'reference/core-plugins/stellate-plugin/index' },
                     items: [{ type: 'autogenerated', dirName: 'reference/core-plugins/stellate-plugin' }],
                 },
+                {
+                    type: 'category',
+                    label: 'TelemetryPlugin',
+                    link: { type: 'doc', id: 'reference/core-plugins/telemetry-plugin/index' },
+                    items: [{ type: 'autogenerated', dirName: 'reference/core-plugins/telemetry-plugin' }],
+                },
             ],
         },
         {

+ 35 - 4
package-lock.json

@@ -20135,8 +20135,8 @@
       "resolved": "packages/stellate-plugin",
       "link": true
     },
-    "node_modules/@vendure/telemetry": {
-      "resolved": "packages/telemetry",
+    "node_modules/@vendure/telemetry-plugin": {
+      "resolved": "packages/telemetry-plugin",
       "link": true
     },
     "node_modules/@vendure/testing": {
@@ -30812,6 +30812,12 @@
         "node": ">=0.1.90"
       }
     },
+    "node_modules/javascript-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
+      "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==",
+      "license": "MIT"
+    },
     "node_modules/jest-diff": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
@@ -48339,7 +48345,6 @@
         "@nestjs/terminus": "~11.0.0",
         "@nestjs/typeorm": "~11.0.0",
         "@vendure/common": "3.2.2",
-        "@vendure/telemetry": "3.2.2",
         "bcrypt": "^5.1.1",
         "body-parser": "^1.20.2",
         "cookie-session": "^2.1.0",
@@ -49055,16 +49060,42 @@
     "packages/telemetry": {
       "name": "@vendure/telemetry",
       "version": "3.2.2",
+      "extraneous": true,
+      "license": "GPL-3.0-or-later",
+      "dependencies": {
+        "@opentelemetry/api": "^1.9.0",
+        "@opentelemetry/auto-instrumentations-node": "^0.58.0",
+        "@opentelemetry/context-async-hooks": "^2.0.0",
+        "@opentelemetry/exporter-logs-otlp-proto": "^0.200.0",
+        "@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
+        "@opentelemetry/resources": "^2.0.0",
+        "@opentelemetry/sdk-logs": "^0.200.0",
+        "@opentelemetry/sdk-node": "^0.200.0",
+        "@vendure/common": "3.2.2",
+        "javascript-stringify": "^2.1.0"
+      },
+      "devDependencies": {
+        "typescript": "5.8.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/michaelbromley"
+      }
+    },
+    "packages/telemetry-plugin": {
+      "name": "@vendure/telemetry-plugin",
+      "version": "3.2.2",
       "license": "GPL-3.0-or-later",
       "dependencies": {
         "@opentelemetry/api": "^1.9.0",
         "@opentelemetry/auto-instrumentations-node": "^0.58.0",
         "@opentelemetry/context-async-hooks": "^2.0.0",
         "@opentelemetry/exporter-logs-otlp-proto": "^0.200.0",
+        "@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
         "@opentelemetry/resources": "^2.0.0",
         "@opentelemetry/sdk-logs": "^0.200.0",
         "@opentelemetry/sdk-node": "^0.200.0",
-        "@vendure/common": "3.2.2"
+        "@vendure/common": "3.2.2",
+        "javascript-stringify": "^2.1.0"
       },
       "devDependencies": {
         "typescript": "5.8.2"

+ 10 - 12
packages/dev-server/instrumentation.ts

@@ -1,26 +1,24 @@
 import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
 import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
-import { resourceFromAttributes } from '@opentelemetry/resources';
 import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
 import { NodeSDK } from '@opentelemetry/sdk-node';
 import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
-import { getSdkConfiguration } from '@vendure/telemetry';
-
-const traceExporter = new OTLPTraceExporter({
-    url: 'http://localhost:4318/v1/traces',
-});
+import { getSdkConfiguration } from '@vendure/telemetry-plugin/preload';
 
 process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://localhost:3100/otlp';
 process.env.OTEL_LOGS_EXPORTER = 'otlp';
+process.env.OTEL_RESOURCE_ATTRIBUTES = 'service.name=vendure-dev-server';
 
+const traceExporter = new OTLPTraceExporter({
+    url: 'http://localhost:4318/v1/traces',
+});
 const logExporter = new OTLPLogExporter();
 
-const config = getSdkConfiguration(false, {
-    spanProcessors: [new BatchSpanProcessor(traceExporter)],
-    logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
-    resource: resourceFromAttributes({
-        'service.name': 'vendure-dev-server',
-    }),
+const config = getSdkConfiguration({
+    config: {
+        spanProcessors: [new BatchSpanProcessor(traceExporter)],
+        logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
+    },
 });
 
 const sdk = new NodeSDK(config);

+ 17 - 5
packages/telemetry/package.json → packages/telemetry-plugin/package.json

@@ -1,5 +1,5 @@
 {
-    "name": "@vendure/telemetry",
+    "name": "@vendure/telemetry-plugin",
     "version": "3.2.2",
     "description": "Telemetry for Vendure",
     "repository": {
@@ -23,15 +23,27 @@
     "files": [
         "dist/**/*"
     ],
+    "exports": {
+        ".": {
+            "types": "./dist/index.d.ts",
+            "default": "./dist/index.js"
+        },
+        "./preload": {
+            "types": "./dist/instrumentation.d.ts",
+            "default": "./dist/instrumentation.js"
+        }
+    },
     "dependencies": {
-        "@opentelemetry/auto-instrumentations-node": "^0.58.0",
         "@opentelemetry/api": "^1.9.0",
+        "@opentelemetry/auto-instrumentations-node": "^0.58.0",
+        "@opentelemetry/context-async-hooks": "^2.0.0",
+        "@opentelemetry/exporter-logs-otlp-proto": "^0.200.0",
+        "@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
         "@opentelemetry/resources": "^2.0.0",
+        "@opentelemetry/sdk-logs": "^0.200.0",
         "@opentelemetry/sdk-node": "^0.200.0",
-        "@opentelemetry/context-async-hooks": "^2.0.0",
         "@vendure/common": "3.2.2",
-        "@opentelemetry/exporter-logs-otlp-proto": "^0.200.0",
-        "@opentelemetry/sdk-logs": "^0.200.0"
+        "javascript-stringify": "^2.1.0"
     },
     "devDependencies": {
         "typescript": "5.8.2"

+ 74 - 0
packages/telemetry-plugin/src/config/default-method-hooks.ts

@@ -0,0 +1,74 @@
+import { CacheService, EventBus, JobQueue, JobQueueService } from '@vendure/core';
+import { stringify } from 'javascript-stringify';
+
+import { registerMethodHooks } from '../service/method-hooks.service';
+
+export const defaultMethodHooks = [
+    registerMethodHooks(CacheService, {
+        get: {
+            pre: ({ args: [key], span }) => {
+                span.setAttribute('cache.key', key);
+            },
+            post: ({ args: [key], result: hit, span }) => {
+                span.setAttribute('cache.hit', !!hit);
+                if (hit) {
+                    span.addEvent('cache.hit', { key });
+                } else {
+                    span.addEvent('cache.miss', { key });
+                }
+            },
+        },
+        set: {
+            pre: ({ args: [key], span }) => {
+                span.setAttribute('cache.key', key);
+            },
+        },
+        delete: {
+            pre: ({ args: [key], span }) => {
+                span.setAttribute('cache.key', key);
+            },
+        },
+        invalidateTags: {
+            pre: ({ args: [tags], span }) => {
+                span.setAttribute('cache.tags', tags.join(', '));
+            },
+        },
+    }),
+    registerMethodHooks(EventBus, {
+        publish: {
+            pre: ({ args: [event], span }) => {
+                span.setAttribute('event', event.constructor.name);
+                span.setAttribute('event.timestamp', event.createdAt.toISOString());
+            },
+        },
+    }),
+    registerMethodHooks(JobQueueService, {
+        createQueue: {
+            pre: ({ args: [options], span }) => {
+                span.setAttribute('job-queue.name', options.name);
+            },
+        },
+    }),
+    registerMethodHooks(JobQueue, {
+        start: {
+            pre: ({ instance, span }) => {
+                span.setAttribute('job-queue.name', instance.name);
+            },
+        },
+        add: {
+            pre: ({ args: [data, options], span, instance }) => {
+                span.setAttribute('job.queueName', instance.name);
+                span.setAttribute(
+                    'job.data',
+                    stringify(data, null, 2, {
+                        maxDepth: 3,
+                    }) ?? '',
+                );
+                span.setAttribute('job.retries', options?.retries ?? 0);
+            },
+            post({ result, span }) {
+                span.setAttribute('job.buffered', result.id === 'buffered');
+            },
+        },
+    }),
+];

+ 9 - 11
packages/telemetry/src/config/otel-instrumentation-strategy.ts → packages/telemetry-plugin/src/config/otel-instrumentation-strategy.ts

@@ -1,36 +1,34 @@
 import { Span as ApiSpan, SpanStatusCode, trace } from '@opentelemetry/api';
-import { logs } from '@opentelemetry/api-logs';
 import { Injector, InstrumentationStrategy, VENDURE_VERSION, WrappedMethodArgs } from '@vendure/core';
 
-import { SetAttributeFn, SpanAttributeService } from '../service/span-attribute.service';
+import { MethodHooksService } from '../service/method-hooks.service';
 
 export const tracer = trace.getTracer('@vendure/core', VENDURE_VERSION);
-export const otelLogger = logs.getLogger('@vendure/core', VENDURE_VERSION);
+
 const recordException = (span: ApiSpan, error: any) => {
     span.recordException(error);
     span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
 };
 
 export class OtelInstrumentationStrategy implements InstrumentationStrategy {
-    private spanAttributeService: SpanAttributeService;
+    private spanAttributeService: MethodHooksService;
 
     init(injector: Injector) {
-        this.spanAttributeService = injector.get(SpanAttributeService);
+        this.spanAttributeService = injector.get(MethodHooksService);
     }
 
-    wrapMethod({ target, methodName, args, applyOriginalFunction }: WrappedMethodArgs) {
+    wrapMethod({ instance, target, methodName, args, applyOriginalFunction }: WrappedMethodArgs) {
         const spanName = `${String(target.name)}.${String(methodName)}`;
 
         return tracer.startActiveSpan(spanName, {}, span => {
-            const hooks = this.spanAttributeService.getHooks(target, methodName);
-            const setAttribute: SetAttributeFn = (key, value) => span.setAttribute(key, value);
-            hooks?.pre?.(args, setAttribute);
+            const hooks = this.spanAttributeService?.getHooks(target, methodName);
+            hooks?.pre?.({ args, span, instance });
             if (applyOriginalFunction.constructor.name === 'AsyncFunction') {
                 return (
                     applyOriginalFunction()
                         .then((result: any) => {
                             if (hooks?.post) {
-                                hooks.post(result, setAttribute);
+                                hooks.post({ args, result, span, instance });
                             }
                             return result;
                         })
@@ -49,7 +47,7 @@ export class OtelInstrumentationStrategy implements InstrumentationStrategy {
             try {
                 const result = applyOriginalFunction();
                 if (hooks?.post) {
-                    hooks.post(result, setAttribute);
+                    hooks.post({ args, result, span, instance });
                 }
                 return result;
             } catch (error) {

+ 22 - 3
packages/core/src/config/logger/otel-logger.ts → packages/telemetry-plugin/src/config/otel-logger.ts

@@ -1,28 +1,47 @@
-import { SeverityNumber } from '@opentelemetry/api-logs';
+import { logs, SeverityNumber } from '@opentelemetry/api-logs';
+import { DefaultLogger, LogLevel, VENDURE_VERSION, VendureLogger } from '@vendure/core';
 
-import { otelLogger } from '../../instrumentation';
+export const otelLogger = logs.getLogger('@vendure/core', VENDURE_VERSION);
 
-import { VendureLogger } from './vendure-logger';
+export interface OtelLoggerOptions {
+    logToConsole?: LogLevel;
+}
 
 export class OtelLogger implements VendureLogger {
+    private defaultLogger?: DefaultLogger;
+
+    constructor(options: OtelLoggerOptions) {
+        if (options.logToConsole) {
+            this.defaultLogger = new DefaultLogger({
+                level: options.logToConsole,
+                timestamp: false,
+            });
+        }
+    }
+
     debug(message: string, context?: string): void {
         this.emitLog(SeverityNumber.DEBUG, message, context);
+        this.defaultLogger?.debug(message, context);
     }
 
     warn(message: string, context?: string): void {
         this.emitLog(SeverityNumber.WARN, message, context);
+        this.defaultLogger?.warn(message, context);
     }
 
     info(message: string, context?: string): void {
         this.emitLog(SeverityNumber.INFO, message, context);
+        this.defaultLogger?.info(message, context);
     }
 
     error(message: string, context?: string): void {
         this.emitLog(SeverityNumber.ERROR, message, context);
+        this.defaultLogger?.error(message, context);
     }
 
     verbose(message: string, context?: string): void {
         this.emitLog(SeverityNumber.DEBUG, message, context);
+        this.defaultLogger?.verbose(message, context);
     }
 
     private emitLog(severityNumber: SeverityNumber, message: string, context?: string, label?: string): void {

+ 1 - 0
packages/telemetry-plugin/src/constants.ts

@@ -0,0 +1 @@
+export const TELEMETRY_PLUGIN_OPTIONS = Symbol('TELEMETRY_PLUGIN_OPTIONS');

+ 6 - 0
packages/telemetry-plugin/src/index.ts

@@ -0,0 +1,6 @@
+export * from './config/default-method-hooks';
+export * from './config/otel-instrumentation-strategy';
+export * from './config/otel-logger';
+export * from './service/method-hooks.service';
+export * from './telemetry.plugin';
+export * from './types';

+ 116 - 0
packages/telemetry-plugin/src/instrumentation.ts

@@ -0,0 +1,116 @@
+import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
+import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
+import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
+import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
+import { resourceFromAttributes } from '@opentelemetry/resources';
+import {
+    BatchLogRecordProcessor,
+    ConsoleLogRecordExporter,
+    SimpleLogRecordProcessor,
+} from '@opentelemetry/sdk-logs';
+import { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
+import { BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
+// Deep import is intentional: otherwise unwanted code (such as instrumented classes) will get
+// loaded too early before the Otel instrumentation has had a chance to do its thing.
+import { ENABLE_INSTRUMENTATION_ENV_VAR } from '@vendure/core/dist/common/instrument-decorator';
+
+const traceExporter = new OTLPTraceExporter();
+const logExporter = new OTLPLogExporter();
+
+/**
+ * @description
+ * Options for configuring the OpenTelemetry Node SDK.
+ *
+ * @docsCategory core plugins/TelemetryPlugin
+ * @docsPage getSdkConfiguration
+ */
+export interface SdkConfigurationOptions {
+    /**
+     * @description
+     * When set to `true`, the SDK will log spans to the console instead of sending them to an
+     * exporter. This should just be used for debugging purposes.
+     *
+     * @default false
+     */
+    logToConsole?: boolean;
+    /**
+     * @description
+     * The configuration object for the OpenTelemetry Node SDK.
+     */
+    config: Partial<NodeSDKConfiguration>;
+}
+
+/**
+ * @description
+ * Creates a configuration object for the OpenTelemetry Node SDK. This is used to set up a custom
+ * preload script which must be run before the main Vendure server is loaded by means of the
+ * Node.js `--require` flag.
+ *
+ * @example
+ * ```ts
+ * // instrumentation.ts
+ * import { OTLPLogExporter } from '\@opentelemetry/exporter-logs-otlp-proto';
+ * import { OTLPTraceExporter } from '\@opentelemetry/exporter-trace-otlp-http';
+ * import { BatchLogRecordProcessor } from '\@opentelemetry/sdk-logs';
+ * import { NodeSDK } from '\@opentelemetry/sdk-node';
+ * import { BatchSpanProcessor } from '\@opentelemetry/sdk-trace-base';
+ * import { getSdkConfiguration } from '\@vendure/telemetry-plugin/preload';
+ *
+ * process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://localhost:3100/otlp';
+ * process.env.OTEL_LOGS_EXPORTER = 'otlp';
+ * process.env.OTEL_RESOURCE_ATTRIBUTES = 'service.name=vendure-dev-server';
+ *
+ * const traceExporter = new OTLPTraceExporter({
+ *     url: 'http://localhost:4318/v1/traces',
+ * });
+ * const logExporter = new OTLPLogExporter();
+ *
+ * const config = getSdkConfiguration({
+ *     config: {
+ *         spanProcessors: [new BatchSpanProcessor(traceExporter)],
+ *         logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
+ *     },
+ * });
+ *
+ * const sdk = new NodeSDK(config);
+ *
+ * sdk.start();
+ * ```
+ *
+ * This would them be run as:
+ * ```bash
+ * node --require ./dist/instrumentation.js ./dist/server.js
+ * ```
+ *
+ * @docsCategory core plugins/TelemetryPlugin
+ * @docsPage getSdkConfiguration
+ * @docsWeight 0
+ */
+export function getSdkConfiguration(options?: SdkConfigurationOptions): Partial<NodeSDKConfiguration> {
+    // This environment variable is used to enable instrumentation in the Vendure core code.
+    // Without setting this env var, no instrumentation will be applied to any Vendure classes.
+    process.env[ENABLE_INSTRUMENTATION_ENV_VAR] = 'true';
+    const { spanProcessors, logRecordProcessors, ...rest } = options?.config ?? {};
+
+    const devModeAwareConfig: Partial<NodeSDKConfiguration> = options?.logToConsole
+        ? {
+              spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
+              logRecordProcessors: [new SimpleLogRecordProcessor(new ConsoleLogRecordExporter())],
+          }
+        : {
+              spanProcessors: spanProcessors ?? [new BatchSpanProcessor(traceExporter)],
+              logRecordProcessors: logRecordProcessors ?? [new BatchLogRecordProcessor(logExporter)],
+          };
+
+    return {
+        resource: resourceFromAttributes({
+            'service.name': 'vendure',
+            'service.namespace': 'vendure',
+            'service.environment': process.env.NODE_ENV || 'development',
+        }),
+        ...devModeAwareConfig,
+        contextManager: new AsyncLocalStorageContextManager(),
+        instrumentations: [getNodeAutoInstrumentations()],
+        ...rest,
+    };
+}

+ 88 - 0
packages/telemetry-plugin/src/service/method-hooks.service.ts

@@ -0,0 +1,88 @@
+import { Injectable } from '@nestjs/common';
+import { Span } from '@opentelemetry/api';
+import { Type } from '@vendure/common/lib/shared-types';
+import { getInstrumentedClassTarget, Logger } from '@vendure/core';
+
+import { MethodHookConfig } from '../types';
+
+/**
+ * Extracts only the method names from a class type T
+ */
+export type MethodNames<T> = {
+    // eslint-disable-next-line @typescript-eslint/ban-types
+    [K in keyof T]: T[K] extends Function ? K : never;
+}[keyof T];
+
+export type Unwrap<T> = T extends Promise<infer U> ? U : T;
+
+type MethodType<T, K extends keyof T> = T[K] extends (...args: any[]) => any ? T[K] : never;
+
+export interface InstrumentedMethodHooks<T, Method extends MethodNames<T>> {
+    pre?: (input: { instance: T; args: Parameters<MethodType<T, Method>>; span: Span }) => void;
+    post?: (input: {
+        instance: T;
+        args: Parameters<MethodType<T, Method>>;
+        result: Unwrap<ReturnType<MethodType<T, Method>>>;
+        span: Span;
+    }) => void;
+}
+
+export type MethodHooksForType<T> = {
+    [K in MethodNames<T>]?: InstrumentedMethodHooks<T, K>;
+};
+
+/**
+ * @description
+ * Allows you to register hooks for a specific method of an instrumented class.
+ * These hooks allow extra telemetry actions to be performed on the method.
+ *
+ * They can then be passed to the {@link TelemetryPlugin} via the {@link TelemetryPluginOptions}.
+ *
+ * @example
+ * ```typescript
+ * const productServiceHooks = registerMethodHooks(ProductService, {
+ *     findOne: {
+ *         // This will be called before the method is executed
+ *         pre: ({ args: [ctx, productId], span }) => {
+ *             span.setAttribute('productId', productId);
+ *         },
+ *         // This will be called after the method is executed
+ *         post: ({ result, span }) => {
+ *             span.setAttribute('found', !!result);
+ *         },
+ *     },
+ * });
+ * ```
+ *
+ * @since 3.3.0
+ * @docsCategory core plugins/TelemetryPlugin
+ */
+export function registerMethodHooks<T>(target: Type<T>, hooks: MethodHooksForType<T>): MethodHookConfig<T> {
+    return {
+        target,
+        hooks,
+    };
+}
+
+@Injectable()
+export class MethodHooksService {
+    private hooksMap = new Map<any, { [methodName: string]: InstrumentedMethodHooks<any, any> }>();
+
+    registerHooks<T>(target: Type<T>, hooks: MethodHooksForType<T>): void {
+        const instrumentedClassTarget = getInstrumentedClassTarget(target);
+        if (!instrumentedClassTarget) {
+            Logger.error(`Cannot register hooks for non-instrumented class: ${target.name}`);
+            return;
+        }
+        const existingHooks = this.hooksMap.get(instrumentedClassTarget);
+        const combinedHooks = {
+            ...existingHooks,
+            ...hooks,
+        };
+        this.hooksMap.set(instrumentedClassTarget, combinedHooks);
+    }
+
+    getHooks<T>(target: T, methodName: string): InstrumentedMethodHooks<T, any> | undefined {
+        return this.hooksMap.get(target)?.[methodName];
+    }
+}

+ 137 - 0
packages/telemetry-plugin/src/telemetry.plugin.ts

@@ -0,0 +1,137 @@
+import { Inject } from '@nestjs/common';
+import { ENABLE_INSTRUMENTATION_ENV_VAR, PluginCommonModule, VendurePlugin } from '@vendure/core';
+
+import { defaultMethodHooks } from './config/default-method-hooks';
+import { OtelInstrumentationStrategy } from './config/otel-instrumentation-strategy';
+import { OtelLogger } from './config/otel-logger';
+import { TELEMETRY_PLUGIN_OPTIONS } from './constants';
+import { MethodHooksService } from './service/method-hooks.service';
+import { TelemetryPluginOptions } from './types';
+
+/**
+ * @description
+ * The TelemetryPlugin is used to instrument the Vendure application and collect telemetry data using
+ * [OpenTelemetry](https://opentelemetry.io/).
+ *
+ * ## Installation
+ *
+ * ```
+ * npm install \@vendure/telemetry-plugin
+ * ```
+ *
+ * ## Configuration
+ *
+ * The plugin is configured via the `TelemetryPlugin.init()` method. This method accepts an options object
+ * which defines the OtelLogger options and method hooks.
+ *
+ * @example
+ * ```ts
+ * import { VendureConfig } from '\@vendure/core';
+ * import { TelemetryPlugin, registerMethodHooks } from '\@vendure/telemetry-plugin';
+ *
+ * export const config: VendureConfig = {
+ *   // ...
+ *   plugins: [
+ *     TelemetryPlugin.init({
+ *       loggerOptions: {
+ *         // Log to the console at the verbose level
+ *         console: LogLevel.Verbose,
+ *       },
+ *     }),
+ *   ],
+ * };
+ * ```
+ *
+ * ## Preloading the SDK
+ *
+ * In order to use the OpenTelemetry SDK, you must preload it before the Vendure server is started.
+ * This is done by using the `--require` flag when starting the server with a custom preload script.
+ *
+ * Create a file named `instrumentation.ts` in the root of your project and add the following code:
+ *
+ * ```ts
+ * import { OTLPLogExporter } from '\@opentelemetry/exporter-logs-otlp-proto';
+ * import { OTLPTraceExporter } from '\@opentelemetry/exporter-trace-otlp-http';
+ * import { BatchLogRecordProcessor } from '\@opentelemetry/sdk-logs';
+ * import { NodeSDK } from '\@opentelemetry/sdk-node';
+ * import { BatchSpanProcessor } from '\@opentelemetry/sdk-trace-base';
+ * import { getSdkConfiguration } from '\@vendure/telemetry-plugin/preload';
+ *
+ * // In this example we are using Loki as the OTLP endpoint for logging
+ * process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://localhost:3100/otlp';
+ * process.env.OTEL_LOGS_EXPORTER = 'otlp';
+ * process.env.OTEL_RESOURCE_ATTRIBUTES = 'service.name=vendure-dev-server';
+ *
+ * // We are using Jaeger as the OTLP endpoint for tracing
+ * const traceExporter = new OTLPTraceExporter({
+ *     url: 'http://localhost:4318/v1/traces',
+ * });
+ * const logExporter = new OTLPLogExporter();
+ *
+ * // The getSdkConfiguration method returns a configuration object for the OpenTelemetry Node SDK.
+ * // It also performs other configuration tasks such as setting a special environment variable
+ * // to enable instrumentation in the Vendure core code.
+ * const config = getSdkConfiguration({
+ *     config: {
+ *         // Pass in any custom configuration options for the Node SDK here
+ *         spanProcessors: [new BatchSpanProcessor(traceExporter)],
+ *         logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
+ *     },
+ * });
+ *
+ * const sdk = new NodeSDK(config);
+ *
+ * sdk.start();
+ * ```
+ *
+ * The server would then be started with the following command:
+ *
+ * ```bash
+ * node --require ./dist/instrumentation.js ./dist/server.js
+ * ```
+ *
+ * or for development with ts-node:
+ *
+ * ```bash
+ * npx ts-node --require ./src/instrumentation.ts ./src/server.ts
+ * ```
+ *
+ * @since 3.3.0
+ * @docsCategory core plugins/TelemetryPlugin
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [
+        MethodHooksService,
+        {
+            provide: TELEMETRY_PLUGIN_OPTIONS,
+            useFactory: () => TelemetryPlugin.options,
+        },
+    ],
+    configuration: config => {
+        config.systemOptions.instrumentationStrategy = new OtelInstrumentationStrategy();
+        config.logger = new OtelLogger(TelemetryPlugin.options.loggerOptions ?? {});
+        return config;
+    },
+    // compatibility: '>3.3.0',
+})
+export class TelemetryPlugin {
+    static options: TelemetryPluginOptions = {};
+
+    constructor(
+        methodHooksService: MethodHooksService,
+        @Inject(TELEMETRY_PLUGIN_OPTIONS) options: TelemetryPluginOptions,
+    ) {
+        if (process.env[ENABLE_INSTRUMENTATION_ENV_VAR]) {
+            const allMethodHooks = [...defaultMethodHooks, ...(options.methodHooks ?? [])];
+            for (const methodHook of allMethodHooks) {
+                methodHooksService.registerHooks(methodHook.target, methodHook.hooks);
+            }
+        }
+    }
+
+    static init(options: TelemetryPluginOptions) {
+        TelemetryPlugin.options = options;
+        return TelemetryPlugin;
+    }
+}

+ 66 - 0
packages/telemetry-plugin/src/types.ts

@@ -0,0 +1,66 @@
+import { Type } from '@vendure/common/lib/shared-types';
+
+import { OtelLoggerOptions } from './config/otel-logger';
+import { MethodHooksForType } from './service/method-hooks.service';
+
+export interface MethodHookConfig<T> {
+    target: Type<T>;
+    hooks: MethodHooksForType<T>;
+}
+
+/**
+ * @description
+ * Configuration options for the TelemetryPlugin.
+ *
+ * @since 3.3.0
+ * @docsCategory core plugins/TelemetryPlugin
+ */
+export interface TelemetryPluginOptions {
+    /**
+     * @description
+     * The options for the OtelLogger.
+     *
+     * For example, to also include logging to the console, you can use the following:
+     * ```ts
+     * import { LogLevel } from '\@vendure/core';
+     * import { TelemetryPlugin } from '\@vendure/telemetry-plugin';
+     *
+     * TelemetryPlugin.init({
+     *     loggerOptions: {
+     *         console: LogLevel.Verbose,
+     *     },
+     * });
+     * ```
+     */
+    loggerOptions?: OtelLoggerOptions;
+    /**
+     * @description
+     * Method hooks allow you to add extra telemetry actions to specific methods.
+     * To define hooks on a method, use the {@link registerMethodHooks} function.
+     *
+     * @example
+     * ```ts
+     * import { TelemetryPlugin, registerMethodHooks } from '\@vendure/telemetry-plugin';
+     *
+     * TelemetryPlugin.init({
+     *   methodHooks: [
+     *     registerMethodHooks(ProductService, {
+     *
+     *       // Define some hooks for the `findOne` method
+     *       findOne: {
+     *         // This will be called before the method is executed
+     *         pre: ({ args: [ctx, productId], span }) => {
+     *           span.setAttribute('productId', productId);
+     *         },
+     *         // This will be called after the method is executed
+     *         post: ({ result, span }) => {
+     *           span.setAttribute('found', !!result);
+     *         },
+     *       },
+     *     }),
+     *   ],
+     * });
+     * ```
+     */
+    methodHooks?: Array<MethodHookConfig<any>>;
+}

+ 1 - 1
packages/telemetry/tsconfig.build.json → packages/telemetry-plugin/tsconfig.build.json

@@ -3,5 +3,5 @@
     "compilerOptions": {
         "outDir": "./dist"
     },
-    "files": ["./src/index.ts"]
+    "files": ["./src/index.ts", "./src/instrumentation.ts"],
 }

+ 0 - 0
packages/telemetry/tsconfig.json → packages/telemetry-plugin/tsconfig.json


+ 0 - 64
packages/telemetry/src/decorator/span.ts

@@ -1,64 +0,0 @@
-// packages/tracing-utils/createSpanDecorator.ts
-import { Span as ApiSpan, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
-
-import { copyMetadata } from '../utils/metadata';
-
-const recordException = (span: ApiSpan, error: any) => {
-    span.recordException(error);
-    span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
-};
-
-/**
- * @description
- * Returns a `@Span()` method decorator bound to the given tracer.
- *
- * Usage in each package:
- *   export const Span = createSpanDecorator(tracer);
- *
- * @since 3.3.0
- */
-export function createSpanDecorator(tracer: Tracer) {
-    return function Span(name?: string, options: SpanOptions = {}): MethodDecorator {
-        return (target: any, propertyKey: PropertyKey, propertyDescriptor: PropertyDescriptor) => {
-            const originalFunction = propertyDescriptor.value;
-            const wrappedFunction = function PropertyDescriptor(...args: any[]) {
-                const spanName = name || `${String(target.constructor.name)}.${String(propertyKey)}`;
-
-                return tracer.startActiveSpan(spanName, options, span => {
-                    if (originalFunction.constructor.name === 'AsyncFunction') {
-                        return (
-                            originalFunction
-                                // @ts-expect-error
-                                .apply(this, args)
-                                // @ts-expect-error
-                                .catch(error => {
-                                    recordException(span, error);
-                                    // Throw error to propagate it further
-                                    throw error;
-                                })
-                                .finally(() => {
-                                    span.end();
-                                })
-                        );
-                    }
-
-                    try {
-                        // @ts-expect-error
-                        return originalFunction.apply(this, args);
-                    } catch (error) {
-                        recordException(span, error);
-
-                        // throw for further propagation
-                        throw error;
-                    } finally {
-                        span.end();
-                    }
-                });
-            };
-
-            propertyDescriptor.value = wrappedFunction;
-
-            copyMetadata(originalFunction, wrappedFunction);
-        };
-    };
-}

+ 0 - 5
packages/telemetry/src/index.ts

@@ -1,5 +0,0 @@
-export * from './decorator/span';
-export * from './instrumentation';
-export * from './telemetry.plugin';
-export * from './tracing/trace.service';
-export * from './utils/span';

+ 0 - 35
packages/telemetry/src/instrumentation.ts

@@ -1,35 +0,0 @@
-import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
-import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
-import { resourceFromAttributes } from '@opentelemetry/resources';
-import { ConsoleLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs';
-import { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
-import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
-
-export function getSdkConfiguration(
-    devMode: boolean = false,
-    config: Partial<NodeSDKConfiguration> = {},
-): Partial<NodeSDKConfiguration> {
-    const { spanProcessors, logRecordProcessors, ...rest } = config;
-
-    const devModeAwareConfig: Partial<NodeSDKConfiguration> = devMode
-        ? {
-              spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
-              logRecordProcessors: [new SimpleLogRecordProcessor(new ConsoleLogRecordExporter())],
-          }
-        : {
-              spanProcessors,
-              logRecordProcessors,
-          };
-
-    return {
-        resource: resourceFromAttributes({
-            'service.name': 'vendure',
-            'service.namespace': 'vendure',
-            'service.environment': process.env.NODE_ENV || 'development',
-        }),
-        ...devModeAwareConfig,
-        contextManager: new AsyncLocalStorageContextManager(),
-        instrumentations: [getNodeAutoInstrumentations()],
-        ...rest,
-    };
-}

+ 0 - 46
packages/telemetry/src/service/span-attribute.service.ts

@@ -1,46 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { AttributeValue } from '@opentelemetry/api';
-import { Type } from '@vendure/common/lib/shared-types';
-import { getInstrumentedClassTarget } from '@vendure/core';
-
-/**
- * Extracts only the method names from a class type T
- */
-export type MethodNames<T> = {
-    // eslint-disable-next-line @typescript-eslint/ban-types
-    [K in keyof T]: T[K] extends Function ? K : never;
-}[keyof T];
-
-export type Unwrap<T> = T extends Promise<infer U> ? U : T;
-
-export type SetAttributeFn = (key: string, value: AttributeValue) => void;
-
-type MethodType<T, K extends keyof T> = T[K] extends (...args: any[]) => any ? T[K] : never;
-
-export interface InstrumentedMethodHooks<T, Method extends MethodNames<T>> {
-    pre?: (args: Parameters<MethodType<T, Method>>, setAttribute: SetAttributeFn) => void;
-    post?: (result: Unwrap<ReturnType<MethodType<T, Method>>>, setAttribute: SetAttributeFn) => void;
-}
-
-export type MethodHooksForType<T> = {
-    [K in MethodNames<T>]?: InstrumentedMethodHooks<T, K>;
-};
-
-@Injectable()
-export class SpanAttributeService {
-    private hooksMap = new Map<any, { [methodName: string]: InstrumentedMethodHooks<any, any> }>();
-
-    registerHooks<T>(target: Type<T>, hooks: MethodHooksForType<T>): void {
-        const actualTarget = getInstrumentedClassTarget(target) ?? target;
-        const existingHooks = this.hooksMap.get(actualTarget);
-        const combinedHooks = {
-            ...existingHooks,
-            ...hooks,
-        };
-        this.hooksMap.set(actualTarget, combinedHooks);
-    }
-
-    getHooks<T>(target: T, methodName: string): InstrumentedMethodHooks<T, any> | undefined {
-        return this.hooksMap.get(target)?.[methodName];
-    }
-}

+ 0 - 27
packages/telemetry/src/telemetry.plugin.ts

@@ -1,27 +0,0 @@
-import { PluginCommonModule, ProductService, VendurePlugin } from '@vendure/core';
-
-import { OtelInstrumentationStrategy } from './config/otel-instrumentation-strategy';
-import { SpanAttributeService } from './service/span-attribute.service';
-
-@VendurePlugin({
-    imports: [PluginCommonModule],
-    providers: [SpanAttributeService],
-    configuration: config => {
-        config.systemOptions.instrumentationStrategy = new OtelInstrumentationStrategy();
-        return config;
-    },
-})
-export class TelemetryPlugin {
-    constructor(spanAttributeService: SpanAttributeService) {
-        spanAttributeService.registerHooks(ProductService, {
-            findOne: {
-                pre([ctx, productId], setAttribute) {
-                    setAttribute('productId', productId);
-                },
-                post(product, setAttribute) {
-                    setAttribute('found', !!product);
-                },
-            },
-        });
-    }
-}

+ 0 - 13
packages/telemetry/src/tracing/trace.service.ts

@@ -1,13 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { context, Span, trace, Tracer } from '@opentelemetry/api';
-
-@Injectable()
-export class TraceService {
-    public getSpan(): Span | undefined {
-        return trace.getSpan(context.active());
-    }
-
-    public startSpan(tracer: Tracer, name: string): Span {
-        return tracer.startSpan(name);
-    }
-}

+ 0 - 1
packages/telemetry/src/types.ts

@@ -1 +0,0 @@
-export interface TelemetryPluginOptions {}

+ 0 - 8
packages/telemetry/src/utils/metadata.ts

@@ -1,8 +0,0 @@
-export const copyMetadata = (originalFunction: any, newFunction: any): void => {
-    // Get the current metadata and set onto the wrapper
-    // to ensure other decorators ( ie: NestJS EventPattern / RolesGuard )
-    // won't be affected by the use of this instrumentation
-    Reflect.getMetadataKeys(originalFunction).forEach(metadataKey => {
-        Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, originalFunction), newFunction);
-    });
-};

+ 0 - 5
packages/telemetry/src/utils/span.ts

@@ -1,5 +0,0 @@
-import { context, Span, trace } from '@opentelemetry/api';
-
-export function getActiveSpan(): Span | undefined {
-    return trace.getSpan(context.active());
-}

+ 4 - 0
scripts/docs/generate-typescript-docs.ts

@@ -57,6 +57,10 @@ const sections: DocsSectionConfig[] = [
         sourceDirs: ['packages/sentry-plugin/src/'],
         outputPath: '',
     },
+    {
+        sourceDirs: ['packages/telemetry-plugin/src/'],
+        outputPath: '',
+    },
     {
         sourceDirs: ['packages/admin-ui/src/lib/', 'packages/ui-devkit/src/'],
         exclude: [/generated-types/],