@adonisjs/otel 1.0.0 → 1.1.0

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
 
@@ -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).
@@ -10,13 +10,14 @@ export declare class HttpUrlFilter {
10
10
  static readonly STATIC_FILE_EXTENSIONS: Set<string>;
11
11
  constructor(config?: HttpInstrumentationConfig);
12
12
  /**
13
- * Check if a URL should be ignored (not traced).
14
- * A URL is ignored if:
13
+ * Check if a request should be ignored (not traced).
14
+ * A request is ignored if:
15
+ * - It's an OPTIONS request and ignoreOptionsRequests is true (default)
15
16
  * - It's a static file and ignoreStaticFiles is true (default)
16
17
  * - It matches the ignored URLs list (exact or prefix pattern)
17
18
  * - The user's custom hook returns true
18
19
  */
19
- shouldIgnore(url: string | undefined): boolean;
20
+ shouldIgnore(request: IgnoreRequestInfo): boolean;
20
21
  /**
21
22
  * Apply HTTP instrumentation config to the merged config map
22
23
  */
@@ -43,10 +43,12 @@ export class HttpUrlFilter {
43
43
  ]);
44
44
  #ignoredUrls;
45
45
  #ignoreStaticFiles;
46
+ #ignoreOptionsRequests;
46
47
  #userIgnoreHook;
47
48
  constructor(config) {
48
49
  this.#ignoredUrls = this.#buildIgnoredUrls(config);
49
50
  this.#ignoreStaticFiles = config?.ignoreStaticFiles !== false;
51
+ this.#ignoreOptionsRequests = config?.ignoreOptionsRequests !== false;
50
52
  this.#userIgnoreHook = config?.ignoreIncomingRequestHook;
51
53
  }
52
54
  #buildIgnoredUrls(config) {
@@ -72,13 +74,17 @@ export class HttpUrlFilter {
72
74
  return urlPath === pattern || urlPath.startsWith(pattern + '/');
73
75
  }
74
76
  /**
75
- * Check if a URL should be ignored (not traced).
76
- * A URL is ignored if:
77
+ * Check if a request should be ignored (not traced).
78
+ * A request is ignored if:
79
+ * - It's an OPTIONS request and ignoreOptionsRequests is true (default)
77
80
  * - It's a static file and ignoreStaticFiles is true (default)
78
81
  * - It matches the ignored URLs list (exact or prefix pattern)
79
82
  * - The user's custom hook returns true
80
83
  */
81
- shouldIgnore(url) {
84
+ shouldIgnore(request) {
85
+ const { url, method } = request;
86
+ if (this.#ignoreOptionsRequests && method === 'OPTIONS')
87
+ return true;
82
88
  if (!url)
83
89
  return false;
84
90
  const urlPath = url.split('?')[0];
@@ -87,7 +93,7 @@ export class HttpUrlFilter {
87
93
  if (this.#ignoredUrls.some((pattern) => this.#matchesPattern(urlPath, pattern)))
88
94
  return true;
89
95
  if (this.#userIgnoreHook)
90
- return this.#userIgnoreHook({ url });
96
+ return this.#userIgnoreHook(request);
91
97
  return false;
92
98
  }
93
99
  /**
@@ -102,7 +108,7 @@ export class HttpUrlFilter {
102
108
  mergedConfig[httpKey] = {
103
109
  ...currentConfig,
104
110
  ...userHttpConfig,
105
- ignoreIncomingRequestHook: (req) => this.shouldIgnore(req.url),
111
+ ignoreIncomingRequestHook: (req) => this.shouldIgnore({ url: req.url, method: req.method }),
106
112
  };
107
113
  }
108
114
  }
@@ -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,7 +2,7 @@ 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';
@@ -166,15 +166,14 @@ export class OtelManager {
166
166
  return;
167
167
  }
168
168
  const userLogHook = userPinoConfig?.logHook;
169
- const internalLogHook = (span) => {
170
- const httpContext = HttpContext.get();
171
- span.setAttribute('http.route', httpContext?.route?.pattern || '');
172
- };
173
169
  mergedConfig[pinoKey] = {
174
170
  ...currentConfig,
175
171
  ...userPinoConfig,
176
172
  logHook: (span, record) => {
177
- internalLogHook(span);
173
+ const httpContext = HttpContext.get();
174
+ const routePattern = httpContext?.route?.pattern;
175
+ if (routePattern)
176
+ span.setAttribute(ATTR_HTTP_ROUTE, routePattern);
178
177
  if (userLogHook)
179
178
  userLogHook(span, record);
180
179
  },
@@ -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.0",
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",