@adonisjs/otel 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ OpenTelemetry integration for AdonisJS with sensible defaults and zero-config se
10
10
 
11
11
  ## Official Documentation
12
12
 
13
- The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/digging-deeper/otel)
13
+ The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/digging-deeper/open-telemetry)
14
14
 
15
15
  ## Contributing
16
16
 
@@ -0,0 +1,2 @@
1
+ declare const _default: import("util").DebugLogger;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { debuglog } from 'node:util';
2
+ export default debuglog('adonisjs:otel');
@@ -70,17 +70,25 @@ export declare function handleError(span: Span, error: Error): void;
70
70
  * Set user information on the currently active span.
71
71
  *
72
72
  * Uses OpenTelemetry semantic conventions for user attributes.
73
+ * Custom attributes are prefixed with `user.` automatically.
73
74
  *
74
75
  * @example
75
76
  * ```ts
76
77
  * import { setUser } from '@adonisjs/otel'
77
78
  *
78
- * // In a controller or middleware
79
+ * // Basic usage
79
80
  * setUser({
80
81
  * id: auth.user.id,
81
82
  * email: auth.user.email,
82
83
  * role: auth.user.role,
83
84
  * })
85
+ *
86
+ * // With custom attributes
87
+ * setUser({
88
+ * id: auth.user.id,
89
+ * tenantId: auth.user.tenantId,
90
+ * plan: 'enterprise',
91
+ * })
84
92
  * ```
85
93
  */
86
94
  export declare function setUser(user: UserContextResult): void;
@@ -107,17 +107,25 @@ const ATTR_USER_ROLES = 'user.roles';
107
107
  * Set user information on the currently active span.
108
108
  *
109
109
  * Uses OpenTelemetry semantic conventions for user attributes.
110
+ * Custom attributes are prefixed with `user.` automatically.
110
111
  *
111
112
  * @example
112
113
  * ```ts
113
114
  * import { setUser } from '@adonisjs/otel'
114
115
  *
115
- * // In a controller or middleware
116
+ * // Basic usage
116
117
  * setUser({
117
118
  * id: auth.user.id,
118
119
  * email: auth.user.email,
119
120
  * role: auth.user.role,
120
121
  * })
122
+ *
123
+ * // With custom attributes
124
+ * setUser({
125
+ * id: auth.user.id,
126
+ * tenantId: auth.user.tenantId,
127
+ * plan: 'enterprise',
128
+ * })
121
129
  * ```
122
130
  */
123
131
  export function setUser(user) {
@@ -131,6 +139,14 @@ export function setUser(user) {
131
139
  attributes[ATTR_USER_EMAIL] = user.email;
132
140
  if (user.role)
133
141
  attributes[ATTR_USER_ROLES] = [user.role];
142
+ // Add custom attributes with user. prefix
143
+ for (const [key, value] of Object.entries(user)) {
144
+ if (key === 'id' || key === 'email' || key === 'role')
145
+ continue;
146
+ if (value === undefined)
147
+ continue;
148
+ attributes[`user.${key}`] = value;
149
+ }
134
150
  span.setAttributes(attributes);
135
151
  }
136
152
  /**
@@ -1,5 +1,5 @@
1
1
  import type { InstrumentationConfigMap } from '@opentelemetry/auto-instrumentations-node';
2
- import type { HttpInstrumentationConfig } from './types/instrumentations.js';
2
+ import type { HttpInstrumentationConfig, IgnoreRequestInfo } from './types/instrumentations.js';
3
3
  /**
4
4
  * Handles URL filtering for OpenTelemetry HTTP instrumentation.
5
5
  * Determines which requests should be ignored (not traced).
@@ -8,15 +8,17 @@ export declare class HttpUrlFilter {
8
8
  #private;
9
9
  static readonly DEFAULT_IGNORED_URLS: string[];
10
10
  static readonly STATIC_FILE_EXTENSIONS: Set<string>;
11
+ static readonly VITE_DEV_PATTERNS: string[];
11
12
  constructor(config?: HttpInstrumentationConfig);
12
13
  /**
13
- * Check if a URL should be ignored (not traced).
14
- * A URL is ignored if:
14
+ * Check if a request should be ignored (not traced).
15
+ * A request is ignored if:
16
+ * - It's an OPTIONS request and ignoreOptionsRequests is true (default)
15
17
  * - It's a static file and ignoreStaticFiles is true (default)
16
18
  * - It matches the ignored URLs list (exact or prefix pattern)
17
19
  * - The user's custom hook returns true
18
20
  */
19
- shouldIgnore(url: string | undefined): boolean;
21
+ shouldIgnore(request: IgnoreRequestInfo): boolean;
20
22
  /**
21
23
  * Apply HTTP instrumentation config to the merged config map
22
24
  */
@@ -1,3 +1,4 @@
1
+ import debug from './debug.js';
1
2
  /**
2
3
  * Handles URL filtering for OpenTelemetry HTTP instrumentation.
3
4
  * Determines which requests should be ignored (not traced).
@@ -40,13 +41,18 @@ export class HttpUrlFilter {
40
41
  'ogg',
41
42
  'wav',
42
43
  'pdf',
44
+ 'vue',
45
+ 'svelte',
43
46
  ]);
47
+ static VITE_DEV_PATTERNS = ['/@vite/', '/@id/', '/@fs/', '/__vite'];
44
48
  #ignoredUrls;
45
49
  #ignoreStaticFiles;
50
+ #ignoreOptionsRequests;
46
51
  #userIgnoreHook;
47
52
  constructor(config) {
48
53
  this.#ignoredUrls = this.#buildIgnoredUrls(config);
49
54
  this.#ignoreStaticFiles = config?.ignoreStaticFiles !== false;
55
+ this.#ignoreOptionsRequests = config?.ignoreOptionsRequests !== false;
50
56
  this.#userIgnoreHook = config?.ignoreIncomingRequestHook;
51
57
  }
52
58
  #buildIgnoredUrls(config) {
@@ -64,6 +70,9 @@ export class HttpUrlFilter {
64
70
  const extension = lastSegment.slice(dotIndex + 1).toLowerCase();
65
71
  return HttpUrlFilter.STATIC_FILE_EXTENSIONS.has(extension);
66
72
  }
73
+ #isViteDevRequest(url) {
74
+ return HttpUrlFilter.VITE_DEV_PATTERNS.some((pattern) => url.startsWith(pattern));
75
+ }
67
76
  #matchesPattern(urlPath, pattern) {
68
77
  if (pattern.endsWith('/*')) {
69
78
  const prefix = pattern.slice(0, -2);
@@ -72,22 +81,38 @@ export class HttpUrlFilter {
72
81
  return urlPath === pattern || urlPath.startsWith(pattern + '/');
73
82
  }
74
83
  /**
75
- * Check if a URL should be ignored (not traced).
76
- * A URL is ignored if:
84
+ * Check if a request should be ignored (not traced).
85
+ * A request is ignored if:
86
+ * - It's an OPTIONS request and ignoreOptionsRequests is true (default)
77
87
  * - It's a static file and ignoreStaticFiles is true (default)
78
88
  * - It matches the ignored URLs list (exact or prefix pattern)
79
89
  * - The user's custom hook returns true
80
90
  */
81
- shouldIgnore(url) {
91
+ shouldIgnore(request) {
92
+ const { url, method } = request;
93
+ if (this.#ignoreOptionsRequests && method === 'OPTIONS') {
94
+ debug('ignoring request "%s %s" (reason: OPTIONS method)', method, url);
95
+ return true;
96
+ }
82
97
  if (!url)
83
98
  return false;
84
99
  const urlPath = url.split('?')[0];
85
- if (this.#ignoreStaticFiles && this.#isStaticFile(urlPath))
100
+ if (this.#ignoreStaticFiles && this.#isStaticFile(urlPath)) {
101
+ debug('ignoring request "%s %s" (reason: static file)', method, urlPath);
86
102
  return true;
87
- if (this.#ignoredUrls.some((pattern) => this.#matchesPattern(urlPath, pattern)))
103
+ }
104
+ if (this.#ignoreStaticFiles && this.#isViteDevRequest(urlPath)) {
105
+ debug('ignoring request "%s %s" (reason: vite dev pattern)', method, urlPath);
88
106
  return true;
89
- if (this.#userIgnoreHook)
90
- return this.#userIgnoreHook({ url });
107
+ }
108
+ if (this.#ignoredUrls.some((pattern) => this.#matchesPattern(urlPath, pattern))) {
109
+ debug('ignoring request "%s %s" (reason: ignored url pattern)', method, urlPath);
110
+ return true;
111
+ }
112
+ if (this.#userIgnoreHook && this.#userIgnoreHook(request)) {
113
+ debug('ignoring request "%s %s" (reason: user hook)', method, urlPath);
114
+ return true;
115
+ }
91
116
  return false;
92
117
  }
93
118
  /**
@@ -102,7 +127,7 @@ export class HttpUrlFilter {
102
127
  mergedConfig[httpKey] = {
103
128
  ...currentConfig,
104
129
  ...userHttpConfig,
105
- ignoreIncomingRequestHook: (req) => this.shouldIgnore(req.url),
130
+ ignoreIncomingRequestHook: (req) => this.shouldIgnore({ url: req.url, method: req.method }),
106
131
  };
107
132
  }
108
133
  }
@@ -2,19 +2,18 @@ import type { HttpContext } from '@adonisjs/core/http';
2
2
  import type { NextFn } from '@adonisjs/core/types/http';
3
3
  import type { UserContextConfig } from '../types/index.js';
4
4
  /**
5
- * Middleware that enriches the active OpenTelemetry span with AdonisJS
6
- * specific attributes like the route pattern, user info, etc.
5
+ * Enriches the active OpenTelemetry span with AdonisJS-specific attributes.
7
6
  *
8
- * This should be registered as a router middleware to run after the
9
- * route has been resolved.
10
- *
11
- * When Auth module is installed, it will automatically set user
12
- * attributes on the span if a user is authenticated.
7
+ * Should be registered as a router middleware so it runs after route resolution.
8
+ * Automatically extracts user context from Auth module when available.
13
9
  */
14
10
  export default class OtelMiddleware {
15
11
  #private;
16
12
  constructor(options: {
17
13
  userContext?: UserContextConfig | false;
18
14
  });
15
+ /**
16
+ * Enriches active span with route info, user context, and response status.
17
+ */
19
18
  handle(ctx: HttpContext, next: NextFn): Promise<any>;
20
19
  }
@@ -1,15 +1,12 @@
1
- import { trace } from '@opentelemetry/api';
1
+ import { context, trace } from '@opentelemetry/api';
2
+ import { getRPCMetadata, RPCType } from '@opentelemetry/core';
2
3
  import { ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_HTTP_ROUTE, } from '@opentelemetry/semantic-conventions';
3
4
  import { setUser } from '../helpers.js';
4
5
  /**
5
- * Middleware that enriches the active OpenTelemetry span with AdonisJS
6
- * specific attributes like the route pattern, user info, etc.
6
+ * Enriches the active OpenTelemetry span with AdonisJS-specific attributes.
7
7
  *
8
- * This should be registered as a router middleware to run after the
9
- * route has been resolved.
10
- *
11
- * When Auth module is installed, it will automatically set user
12
- * attributes on the span if a user is authenticated.
8
+ * Should be registered as a router middleware so it runs after route resolution.
9
+ * Automatically extracts user context from Auth module when available.
13
10
  */
14
11
  export default class OtelMiddleware {
15
12
  #userContextConfig;
@@ -17,45 +14,55 @@ export default class OtelMiddleware {
17
14
  this.#userContextConfig = options.userContext ?? {};
18
15
  }
19
16
  /**
20
- * Try to extract user from auth and set on span
21
- *
22
- * @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/user/
17
+ * Extracts user from auth context and sets OTEL user attributes.
18
+ * Supports custom resolvers or defaults to ctx.auth.user fields.
23
19
  */
24
20
  async #setUserFromAuth(ctx) {
25
21
  if (this.#userContextConfig === false)
26
22
  return;
27
23
  if (this.#userContextConfig.enabled === false)
28
24
  return;
29
- // Custom resolver takes precedence
30
25
  if (this.#userContextConfig.resolver) {
31
26
  const resolved = await this.#userContextConfig.resolver(ctx);
32
27
  if (resolved)
33
28
  setUser(resolved);
34
29
  return;
35
30
  }
36
- // Default: extract from ctx.auth.user
37
31
  const user = ctx.auth?.user;
38
32
  if (!user)
39
33
  return;
40
34
  setUser({ id: user.id, email: user.email, role: user.role });
41
35
  }
36
+ /**
37
+ * Sets route metadata via RPCMetadata for OTEL HTTP instrumentation.
38
+ * This is the standard mechanism OTEL uses to populate http.route attribute.
39
+ */
40
+ #setRouteViaRpcMetadata(routePattern) {
41
+ const rpcMetadata = getRPCMetadata(context.active());
42
+ if (rpcMetadata?.type === RPCType.HTTP)
43
+ rpcMetadata.route = routePattern;
44
+ }
45
+ /**
46
+ * Updates span name and sets http.route attribute directly.
47
+ * Produces better trace names like "GET /users/:id" instead of just "GET".
48
+ */
49
+ #updateSpanWithRoute(options) {
50
+ options.span?.updateName(`${options.method} ${options.routePattern}`);
51
+ options.span?.setAttribute(ATTR_HTTP_ROUTE, options.routePattern);
52
+ }
53
+ /**
54
+ * Enriches active span with route info, user context, and response status.
55
+ */
42
56
  async handle(ctx, next) {
43
57
  const span = trace.getActiveSpan();
44
58
  if (!span)
45
59
  return next();
46
60
  const { request, route } = ctx;
47
- /**
48
- * Update span name with HTTP method and route pattern
49
- * This gives much better trace names like "GET /users/:id" instead of "GET"
50
- */
51
61
  if (route?.pattern) {
52
- span.updateName(`${request.method()} ${route.pattern}`);
53
- span.setAttribute(ATTR_HTTP_ROUTE, route.pattern);
62
+ this.#setRouteViaRpcMetadata(route.pattern);
63
+ this.#updateSpanWithRoute({ span, method: request.method(), routePattern: route.pattern });
54
64
  }
55
65
  span.setAttributes({ 'adonis.route.name': route?.name ?? 'unknown' });
56
- /**
57
- * Automatically set user context from Auth module
58
- */
59
66
  await this.#setUserFromAuth(ctx);
60
67
  const output = await next();
61
68
  span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, ctx.response.getStatus());
package/build/src/otel.js CHANGED
@@ -2,10 +2,11 @@ import { getNodeAutoInstrumentations, } from '@opentelemetry/auto-instrumentatio
2
2
  import { resourceFromAttributes } from '@opentelemetry/resources';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { ConsoleSpanExporter, ParentBasedSampler, SimpleSpanProcessor, TraceIdRatioBasedSampler, } from '@opentelemetry/sdk-trace-base';
5
- import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
5
+ import { ATTR_HTTP_ROUTE, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from '@opentelemetry/semantic-conventions';
6
6
  import { ATTR_DEPLOYMENT_ENVIRONMENT_NAME, ATTR_SERVICE_INSTANCE_ID, } from '@opentelemetry/semantic-conventions/incubating';
7
7
  import { HttpContext } from '@adonisjs/core/http';
8
8
  import { HttpUrlFilter } from './http_url_filter.js';
9
+ import debug from './debug.js';
9
10
  /**
10
11
  * OpenTelemetry SDK manager for AdonisJS.
11
12
  *
@@ -140,6 +141,10 @@ export class OtelManager {
140
141
  */
141
142
  #buildInstrumentations() {
142
143
  const { customInstances, disabledSet, configOverrides, httpConfig, pinoConfig } = this.#processUserInstrumentations(this.#config.instrumentations);
144
+ if (disabledSet.size > 0)
145
+ debug('disabled instrumentations: %O', [...disabledSet]);
146
+ if (customInstances.length > 0)
147
+ debug('custom instrumentations: %O', customInstances.map((i) => i.instrumentationName));
143
148
  const mergedConfig = this.#buildBaseInstrumentationConfig();
144
149
  for (const [name, config] of Object.entries(configOverrides)) {
145
150
  mergedConfig[name] = {
@@ -166,15 +171,14 @@ export class OtelManager {
166
171
  return;
167
172
  }
168
173
  const userLogHook = userPinoConfig?.logHook;
169
- const internalLogHook = (span) => {
170
- const httpContext = HttpContext.get();
171
- span.setAttribute('http.route', httpContext?.route?.pattern || '');
172
- };
173
174
  mergedConfig[pinoKey] = {
174
175
  ...currentConfig,
175
176
  ...userPinoConfig,
176
177
  logHook: (span, record) => {
177
- internalLogHook(span);
178
+ const httpContext = HttpContext.get();
179
+ const routePattern = httpContext?.route?.pattern;
180
+ if (routePattern)
181
+ span.setAttribute(ATTR_HTTP_ROUTE, routePattern);
178
182
  if (userLogHook)
179
183
  userLogHook(span, record);
180
184
  },
@@ -222,12 +226,14 @@ export class OtelManager {
222
226
  * Start the OpenTelemetry SDK
223
227
  */
224
228
  start() {
229
+ debug('starting otel sdk for service "%s" v%s (%s)', this.serviceName, this.serviceVersion, this.environment);
225
230
  this.sdk.start();
226
231
  }
227
232
  /**
228
233
  * Gracefully shutdown the OpenTelemetry SDK
229
234
  */
230
235
  async shutdown() {
236
+ debug('shutting down otel sdk');
231
237
  await this.sdk.shutdown();
232
238
  }
233
239
  /**
@@ -102,12 +102,14 @@ export interface OtelConfig extends Partial<Omit<NodeSDKConfiguration, 'resource
102
102
  userContext?: false | UserContextConfig;
103
103
  }
104
104
  /**
105
- * Result returned by the user context resolver
105
+ * Result returned by the user context resolver.
106
+ * Supports custom attributes via index signature.
106
107
  */
107
108
  export interface UserContextResult {
108
109
  id: string | number;
109
110
  email?: string;
110
111
  role?: string;
112
+ [key: string]: string | number | boolean | string[] | undefined;
111
113
  }
112
114
  /**
113
115
  * Configuration for automatic user context extraction
@@ -13,6 +13,13 @@ export type InstrumentationValue<K extends keyof InstrumentationConfigMap> = Ins
13
13
  export type CustomInstrumentationValue = Instrumentation | {
14
14
  enabled: false;
15
15
  };
16
+ /**
17
+ * Request info passed to the ignoreIncomingRequestHook
18
+ */
19
+ export interface IgnoreRequestInfo {
20
+ url?: string;
21
+ method?: string;
22
+ }
16
23
  /**
17
24
  * Extended config for @opentelemetry/instrumentation-http.
18
25
  * Adds AdonisJS-specific helpers for URL filtering.
@@ -36,13 +43,16 @@ export interface HttpInstrumentationConfig extends Omit<NonNullable<Instrumentat
36
43
  * @default true
37
44
  */
38
45
  ignoreStaticFiles?: boolean;
46
+ /**
47
+ * Whether to automatically ignore OPTIONS requests (CORS preflight).
48
+ * @default true
49
+ */
50
+ ignoreOptionsRequests?: boolean;
39
51
  /**
40
52
  * Custom hook to ignore specific incoming requests.
41
- * Called AFTER the ignoredUrls and static files checks.
53
+ * Called AFTER the ignoredUrls, static files, and OPTIONS checks.
42
54
  */
43
- ignoreIncomingRequestHook?: (request: {
44
- url?: string;
45
- }) => boolean;
55
+ ignoreIncomingRequestHook?: (request: IgnoreRequestInfo) => boolean;
46
56
  }
47
57
  /**
48
58
  * Extended config for @opentelemetry/instrumentation-pino.
@@ -66,10 +76,16 @@ export interface PinoInstrumentationConfig extends Omit<NonNullable<Instrumentat
66
76
  * Keys of instrumentations with custom extended configs
67
77
  */
68
78
  type ExtendedInstrumentationKeys = '@opentelemetry/instrumentation-http' | '@opentelemetry/instrumentation-pino';
79
+ /**
80
+ * All possible values for any instrumentation key
81
+ */
82
+ type AnyInstrumentationValue = HttpInstrumentationConfig | PinoInstrumentationConfig | InstrumentationConfigMap[keyof InstrumentationConfigMap] | CustomInstrumentationValue;
69
83
  /**
70
84
  * Instrumentations configuration map.
71
85
  *
72
86
  * - HTTP and Pino instrumentations have extended configs
87
+ * - Known OpenTelemetry instrumentations have typed configs with autocomplete
88
+ * - Custom instrumentations can be added with any string key
73
89
  */
74
90
  export type InstrumentationsConfig = {
75
91
  '@opentelemetry/instrumentation-http'?: HttpInstrumentationConfig | Instrumentation | {
@@ -80,5 +96,7 @@ export type InstrumentationsConfig = {
80
96
  };
81
97
  } & {
82
98
  [K in Exclude<keyof InstrumentationConfigMap, ExtendedInstrumentationKeys>]?: InstrumentationValue<K>;
99
+ } & {
100
+ [key: string & {}]: AnyInstrumentationValue | undefined;
83
101
  };
84
102
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adonisjs/otel",
3
3
  "description": "OpenTelemetry integration for AdonisJS with sensible defaults and zero-config setup",
4
- "version": "1.0.0",
4
+ "version": "1.1.1",
5
5
  "engines": {
6
6
  "node": ">=20.6.0"
7
7
  },
@@ -68,6 +68,7 @@
68
68
  "dependencies": {
69
69
  "@opentelemetry/api": "^1.9.0",
70
70
  "@opentelemetry/auto-instrumentations-node": "^0.67.2",
71
+ "@opentelemetry/core": "^2.2.0",
71
72
  "@opentelemetry/exporter-metrics-otlp-grpc": "^0.208.0",
72
73
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.208.0",
73
74
  "@opentelemetry/instrumentation": "^0.208.0",
@@ -88,8 +89,8 @@
88
89
  "@adonisjs/tsconfig": "^1.4.1",
89
90
  "@japa/assert": "^4.1.1",
90
91
  "@japa/runner": "^4.4.0",
92
+ "@julr/otel-instrumentation-clickhouse": "^1.0.2",
91
93
  "@opentelemetry/context-async-hooks": "^2.2.0",
92
- "@opentelemetry/core": "^2.2.0",
93
94
  "@release-it/conventional-changelog": "^10.0.0",
94
95
  "@sentry/node": "^10.30.0",
95
96
  "@swc/core": "^1.15.3",