Skip to content

Commit da1645c

Browse files
authored
feat(server-utils): Add @opentelemetry/instrumentation-koa orchestrion integration (#22146)
Adds `koaChannelIntegration` in `@sentry/server-utils` for injecting orchestrion channels into `koa`. A subscriber wraps each registered layer in a span-creating proxy. Span-helpers are ported from the vendored instrumentation, preserving span names (but adapting to [new conventions](getsentry/sentry-conventions#472)). Also upgraded `@apm-js-collab/tracing-hooks` to get this: apm-js-collab/tracing-hooks#45 to be released (lets us actually patch `koa` - see 1. iteration below). <details> <summary>1. iteration</summary> One thing to know for review: **we instrument `koa-compose`, not `use` from koa.** koa's `use` lives in koa's main entry (`lib/application.js`), and transforming a package's main entry forces its top-level `require` chain through Node's `require(esm)` bridge, which throws on Node < 24.13. 1. Orchestrion instruments by rewriting a module's source at load time (via the ESM load hook). 2. `use` lives in koa's main entry (`lib/application.js`), so to instrument it we transform that file. But transforming a main entry pulls its whole top-level `require` chain into the loader's handling --> and that changes how those `require`s are loaded (through the ESM→CommonJS translator, not the normal sync require path). 3. `koa` is CJS (but support ESM). When `import`ing koa, it loads a shim that loads the CJS code: `import 'koa'` → `dist/koa.mjs` (a ESM shim) → `import '../lib/application.js'` (CJS). That CJS entry has a top-level `require('is-generator-function')`. 4. `is-generator-function` → `require('generator-function')`, and `generator-function` points at an `.mjs` file, which in turn imports `./index.js`. --> So the top-level `require` in koa's `application.js` (CJS) becomes `require(esm)` of an ESM file importing a CJS file. 5. On Node < 24.13 (we pin 20.19.5), the loader can't pre-link this dual-package shape into the `require(esm)` cache, so it throws `request for './index.js' is not in cache`. The failing chain: ``` koa/lib/application.js (CJS) └─ require('is-generator-function') (CJS) └─ require('generator-function') → require(esm) → require.mjs (ESM) └─ import './index.js' (ESM importing CJS) ``` `koa-compose` is koa's zero-dependency dispatch engine, so it's safe to transform, and `compose(app.middleware)` sees the same layers `use` would. Since `@koa/router` also calls `compose` per request, the subscriber uses `getActiveSpan()` to only wrap at app startup (no active span) and skip the per-request router composition.</details> Closes #20758 Linear: https://linear.app/getsentry/issue/JS-2409/rewrite-opentelemetryinstrumentation-koa-to-orchestrion
1 parent b9f91c0 commit da1645c

16 files changed

Lines changed: 621 additions & 27 deletions

File tree

dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ test('Sends an API route transaction', async ({ baseURL }) => {
6565
{
6666
data: {
6767
'koa.name': 'bodyParser',
68+
'code.function.name': 'bodyParser',
6869
'koa.type': 'middleware',
6970
'sentry.op': 'middleware.koa',
7071
'sentry.origin': 'auto.http.otel.koa',
@@ -82,6 +83,7 @@ test('Sends an API route transaction', async ({ baseURL }) => {
8283
{
8384
data: {
8485
'koa.name': '',
86+
'code.function.name': '',
8587
'koa.type': 'middleware',
8688
'sentry.origin': 'auto.http.otel.koa',
8789
'sentry.op': 'middleware.koa',

dev-packages/node-integration-tests/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@growthbook/growthbook": "^1.6.5",
4141
"@hapi/hapi": "^21.3.10",
4242
"@hono/node-server": "^1.19.13",
43+
"@koa/router": "^12.0.1",
4344
"@langchain/anthropic": "^0.3.10",
4445
"@langchain/core": "^0.3.80",
4546
"@langchain/openai": "^0.5.0",
@@ -76,6 +77,7 @@
7677
"ioredis-5": "npm:ioredis@^5.11.0",
7778
"kafkajs": "2.2.4",
7879
"knex": "^2.5.1",
80+
"koa": "^2.15.2",
7981
"lru-memoizer": "2.3.0",
8082
"mongodb": "^3.7.3",
8183
"mongodb-memory-server-global": "^11.0.1",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Router from '@koa/router';
2+
import * as Sentry from '@sentry/node';
3+
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
4+
import Koa from 'koa';
5+
6+
const port = 5698;
7+
8+
const app = new Koa();
9+
10+
// Registered first so it wraps every downstream middleware/route in its try/catch.
11+
Sentry.setupKoaErrorHandler(app);
12+
13+
// Plain middleware -> produces a `middleware.koa` span named after the function.
14+
app.use(async function simpleMiddleware(ctx, next) {
15+
await next();
16+
});
17+
18+
const router = new Router();
19+
20+
router.get('/', ctx => {
21+
ctx.body = 'Hello World!';
22+
});
23+
24+
router.get('/test-param/:id', ctx => {
25+
ctx.body = { id: ctx.params.id };
26+
});
27+
28+
router.get('/error', () => {
29+
throw new Error('Sentry Test Error');
30+
});
31+
32+
app.use(router.routes()).use(router.allowedMethods());
33+
34+
app.listen(port, () => {
35+
sendPortToRunner(port);
36+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { isOrchestrionEnabled } from '../../../utils';
3+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
4+
5+
describe('koa auto-instrumentation', () => {
6+
afterAll(async () => {
7+
cleanupChildProcesses();
8+
});
9+
10+
// `createEsmAndCjsTests` auto-runs this suite with orchestrion on CI. The
11+
// orchestrion path keeps span ops/attributes identical to the OTel path; only
12+
// the origin differs to signal the injection mechanism, so we branch on
13+
// `isOrchestrionEnabled()`.
14+
const origin = isOrchestrionEnabled() ? 'auto.http.orchestrion.koa' : 'auto.http.otel.koa';
15+
16+
const EXPECTED_ERROR_EVENT = {
17+
exception: {
18+
values: [
19+
{
20+
type: 'Error',
21+
value: 'Sentry Test Error',
22+
},
23+
],
24+
},
25+
};
26+
27+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
28+
test('should auto-instrument `koa` router and middleware layers.', async () => {
29+
const runner = createRunner()
30+
.expect({
31+
transaction: {
32+
transaction: 'GET /',
33+
spans: expect.arrayContaining([
34+
// Router layer span (from `@koa/router`), carrying the matched route.
35+
expect.objectContaining({
36+
description: '/',
37+
op: 'router.koa',
38+
origin,
39+
data: expect.objectContaining({
40+
'http.route': '/',
41+
'koa.type': 'router',
42+
'koa.name': '/',
43+
'sentry.op': 'router.koa',
44+
'sentry.origin': origin,
45+
}),
46+
}),
47+
// Plain middleware span.
48+
expect.objectContaining({
49+
description: 'simpleMiddleware',
50+
op: 'middleware.koa',
51+
origin,
52+
data: expect.objectContaining({
53+
'koa.type': 'middleware',
54+
'koa.name': 'simpleMiddleware',
55+
'code.function.name': 'simpleMiddleware',
56+
'sentry.op': 'middleware.koa',
57+
'sentry.origin': origin,
58+
}),
59+
}),
60+
]),
61+
},
62+
})
63+
.start();
64+
runner.makeRequest('get', '/');
65+
await runner.completed();
66+
});
67+
68+
test('should assign a parameterized transaction name.', async () => {
69+
const runner = createRunner()
70+
.expect({
71+
transaction: {
72+
transaction: 'GET /test-param/:id',
73+
spans: expect.arrayContaining([
74+
expect.objectContaining({
75+
description: '/test-param/:id',
76+
op: 'router.koa',
77+
origin,
78+
data: expect.objectContaining({
79+
'http.route': '/test-param/:id',
80+
'koa.type': 'router',
81+
'koa.name': '/test-param/:id',
82+
'sentry.op': 'router.koa',
83+
'sentry.origin': origin,
84+
}),
85+
}),
86+
]),
87+
},
88+
})
89+
.start();
90+
runner.makeRequest('get', '/test-param/123');
91+
await runner.completed();
92+
});
93+
94+
test('should capture errors thrown in routes via the koa error handler.', async () => {
95+
const runner = createRunner()
96+
.unordered()
97+
.expect({ transaction: { transaction: 'GET /error' } })
98+
.expect({ event: EXPECTED_ERROR_EVENT })
99+
.start();
100+
runner.makeRequest('get', '/error', { expectError: true });
101+
await runner.completed();
102+
});
103+
});
104+
});

packages/deno/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export { denoPostgresIntegration } from './integrations/postgres';
116116
export { denoAmqplibIntegration } from './integrations/amqplib';
117117
export { denoDataloaderIntegration } from './integrations/dataloader';
118118
export { denoKnexIntegration } from './integrations/knex';
119+
export { denoKoaIntegration } from './integrations/koa';
119120
export { denoContextIntegration } from './integrations/context';
120121
export { globalHandlersIntegration } from './integrations/globalhandlers';
121122
export { normalizePathsIntegration } from './integrations/normalizepaths';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { koaChannelIntegration } from '@sentry/server-utils/orchestrion';
2+
import type { KoaChannelIntegrationOptions } from '@sentry/server-utils/orchestrion';
3+
import type { Integration, IntegrationFn } from '@sentry/core';
4+
import { defineIntegration, extendIntegration } from '@sentry/core';
5+
import { setAsyncLocalStorageAsyncContextStrategy } from '../async';
6+
7+
const INTEGRATION_NAME = 'DenoKoa' as const;
8+
9+
/**
10+
* Create spans for `koa` middleware/router layers under Deno. Requires the
11+
* `@sentry/deno/import` loader. Delegates to the shared subscriber in
12+
* `@sentry/server-utils`, adding Deno's `AsyncLocalStorage` context strategy so
13+
* spans nest under the active HTTP server span.
14+
*/
15+
const _denoKoaIntegration = ((options: KoaChannelIntegrationOptions = {}) => {
16+
const inner = koaChannelIntegration(options);
17+
18+
return extendIntegration(inner, {
19+
name: INTEGRATION_NAME,
20+
setupOnce() {
21+
setAsyncLocalStorageAsyncContextStrategy();
22+
},
23+
});
24+
}) satisfies IntegrationFn;
25+
26+
export const denoKoaIntegration = defineIntegration(_denoKoaIntegration) as (
27+
options?: KoaChannelIntegrationOptions,
28+
) => Integration & {
29+
name: 'DenoKoa';
30+
setupOnce: () => void;
31+
};

packages/deno/src/sdk.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { denoServeIntegration } from './integrations/deno-serve';
2525
import { denoHttpIntegration } from './integrations/http';
2626
import { denoAmqplibIntegration } from './integrations/amqplib';
27+
import { denoKoaIntegration } from './integrations/koa';
2728
import { denoMysqlIntegration } from './integrations/mysql';
2829
import { denoPostgresIntegration } from './integrations/postgres';
2930
import { denoRedisIntegration } from './integrations/redis';
@@ -62,7 +63,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
6263
// (or in parallel to) loading the SDK, so we only gate on whether the
6364
// feature is possible. If they're never loaded, it'll just be a no-op.
6465
...(MODULE_REGISTER_HOOKS_SUPPORTED
65-
? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration()]
66+
? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration(), denoKoaIntegration()]
6667
: []),
6768
contextLinesIntegration(),
6869
normalizePathsIntegration(),

packages/deno/test/__snapshots__/mod.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ snapshot[`captureException 1`] = `
118118
"DenoMysql",
119119
"DenoPostgres",
120120
"DenoAmqplib",
121+
"DenoKoa",
121122
"ContextLines",
122123
"NormalizePaths",
123124
"GlobalHandlers",
@@ -196,6 +197,7 @@ snapshot[`captureMessage 1`] = `
196197
"DenoMysql",
197198
"DenoPostgres",
198199
"DenoAmqplib",
200+
"DenoKoa",
199201
"ContextLines",
200202
"NormalizePaths",
201203
"GlobalHandlers",
@@ -281,6 +283,7 @@ snapshot[`captureMessage twice 1`] = `
281283
"DenoMysql",
282284
"DenoPostgres",
283285
"DenoAmqplib",
286+
"DenoKoa",
284287
"ContextLines",
285288
"NormalizePaths",
286289
"GlobalHandlers",
@@ -373,6 +376,7 @@ snapshot[`captureMessage twice 2`] = `
373376
"DenoMysql",
374377
"DenoPostgres",
375378
"DenoAmqplib",
379+
"DenoKoa",
376380
"ContextLines",
377381
"NormalizePaths",
378382
"GlobalHandlers",

packages/node/src/integrations/tracing/koa/vendored/utils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { KoaLayerType, type KoaInstrumentationConfig } from './types';
1111
import type { KoaContext, KoaMiddleware } from './internal-types';
1212
import { AttributeNames } from './enums/AttributeNames';
1313
import type { Attributes } from '@opentelemetry/api';
14-
import { HTTP_ROUTE } from '@sentry/conventions/attributes';
14+
import { CODE_FUNCTION_NAME, HTTP_ROUTE } from '@sentry/conventions/attributes';
1515

1616
export const getMiddlewareMetadata = (
1717
context: KoaContext,
@@ -25,7 +25,7 @@ export const getMiddlewareMetadata = (
2525
if (isRouter) {
2626
return {
2727
attributes: {
28-
[AttributeNames.KOA_NAME]: layerPath?.toString(),
28+
[AttributeNames.KOA_NAME]: layerPath?.toString(), // TODO(v11): remove, replaced by http.route
2929
[AttributeNames.KOA_TYPE]: KoaLayerType.ROUTER,
3030
[HTTP_ROUTE]: layerPath?.toString(),
3131
},
@@ -34,8 +34,9 @@ export const getMiddlewareMetadata = (
3434
} else {
3535
return {
3636
attributes: {
37-
[AttributeNames.KOA_NAME]: layer.name ?? 'middleware',
37+
[AttributeNames.KOA_NAME]: layer.name ?? 'middleware', // TODO(v11): remove, replaced by code.function.name
3838
[AttributeNames.KOA_TYPE]: KoaLayerType.MIDDLEWARE,
39+
[CODE_FUNCTION_NAME]: layer.name ?? 'middleware',
3940
},
4041
name: `middleware - ${layer.name}`,
4142
};

0 commit comments

Comments
 (0)