@open-mercato/telemetry 0.6.7-develop.6834.1.e76f4b8cbc
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/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +71 -0
- package/README.md +164 -0
- package/build.mjs +10 -0
- package/dist/env.js +44 -0
- package/dist/env.js.map +7 -0
- package/dist/facade/context.js +14 -0
- package/dist/facade/context.js.map +7 -0
- package/dist/facade/http.js +18 -0
- package/dist/facade/http.js.map +7 -0
- package/dist/facade/logger-bridge.js +40 -0
- package/dist/facade/logger-bridge.js.map +7 -0
- package/dist/facade/meter.js +16 -0
- package/dist/facade/meter.js.map +7 -0
- package/dist/facade/propagation.js +20 -0
- package/dist/facade/propagation.js.map +7 -0
- package/dist/facade/redact.js +21 -0
- package/dist/facade/redact.js.map +7 -0
- package/dist/facade/report-error.js +26 -0
- package/dist/facade/report-error.js.map +7 -0
- package/dist/facade/serialize.js +35 -0
- package/dist/facade/serialize.js.map +7 -0
- package/dist/facade/tracer.js +21 -0
- package/dist/facade/tracer.js.map +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +7 -0
- package/dist/init.js +98 -0
- package/dist/init.js.map +7 -0
- package/dist/nextjs-config.js +21 -0
- package/dist/nextjs-config.js.map +7 -0
- package/dist/nextjs.js +30 -0
- package/dist/nextjs.js.map +7 -0
- package/dist/provider/console-provider.js +70 -0
- package/dist/provider/console-provider.js.map +7 -0
- package/dist/provider/noop-provider.js +45 -0
- package/dist/provider/noop-provider.js.map +7 -0
- package/dist/provider/otlp-provider.js +212 -0
- package/dist/provider/otlp-provider.js.map +7 -0
- package/dist/provider/registry.js +40 -0
- package/dist/provider/registry.js.map +7 -0
- package/dist/provider/run-span.js +34 -0
- package/dist/provider/run-span.js.map +7 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +7 -0
- package/jest.config.cjs +27 -0
- package/package.json +68 -0
- package/src/__tests__/default-unloaded.test.ts +58 -0
- package/src/__tests__/env-load-order.test.ts +80 -0
- package/src/__tests__/env.test.ts +72 -0
- package/src/__tests__/logger-level.test.ts +64 -0
- package/src/__tests__/nextjs-shutdown.test.ts +71 -0
- package/src/__tests__/nextjs.test.ts +122 -0
- package/src/__tests__/otlp-integration.test.ts +264 -0
- package/src/__tests__/pg-instrumentation.test.ts +88 -0
- package/src/__tests__/pg-pii-config.test.ts +14 -0
- package/src/__tests__/redact.test.ts +92 -0
- package/src/__tests__/registry.test.ts +77 -0
- package/src/__tests__/run-span.test.ts +56 -0
- package/src/__tests__/serialize.test.ts +47 -0
- package/src/__tests__/telemetry.test.ts +172 -0
- package/src/env.ts +71 -0
- package/src/facade/context.ts +17 -0
- package/src/facade/http.ts +25 -0
- package/src/facade/logger-bridge.ts +50 -0
- package/src/facade/meter.ts +20 -0
- package/src/facade/propagation.ts +31 -0
- package/src/facade/redact.ts +54 -0
- package/src/facade/report-error.ts +41 -0
- package/src/facade/serialize.ts +46 -0
- package/src/facade/tracer.ts +25 -0
- package/src/index.ts +30 -0
- package/src/init.ts +134 -0
- package/src/nextjs-config.ts +22 -0
- package/src/nextjs.ts +47 -0
- package/src/provider/console-provider.ts +87 -0
- package/src/provider/noop-provider.ts +53 -0
- package/src/provider/otlp-provider.ts +316 -0
- package/src/provider/registry.ts +60 -0
- package/src/provider/run-span.ts +45 -0
- package/src/types.ts +108 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NoopProvider } from "./noop-provider.js";
|
|
2
|
+
const GLOBAL_KEY = /* @__PURE__ */ Symbol.for("@open-mercato/telemetry.providerRegistry");
|
|
3
|
+
function store() {
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
let reg = g[GLOBAL_KEY];
|
|
6
|
+
if (!reg) {
|
|
7
|
+
reg = { active: new NoopProvider(), registered: /* @__PURE__ */ new Map() };
|
|
8
|
+
g[GLOBAL_KEY] = reg;
|
|
9
|
+
}
|
|
10
|
+
return reg;
|
|
11
|
+
}
|
|
12
|
+
function registerProvider(provider) {
|
|
13
|
+
store().registered.set(provider.name, provider);
|
|
14
|
+
}
|
|
15
|
+
function getRegisteredProvider(name) {
|
|
16
|
+
return store().registered.get(name);
|
|
17
|
+
}
|
|
18
|
+
function getActiveProvider() {
|
|
19
|
+
return store().active;
|
|
20
|
+
}
|
|
21
|
+
function setActiveProvider(provider) {
|
|
22
|
+
store().active = provider;
|
|
23
|
+
}
|
|
24
|
+
function clearActiveProvider() {
|
|
25
|
+
store().active = new NoopProvider();
|
|
26
|
+
}
|
|
27
|
+
function resetActiveProvider() {
|
|
28
|
+
const reg = store();
|
|
29
|
+
clearActiveProvider();
|
|
30
|
+
reg.registered.clear();
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
clearActiveProvider,
|
|
34
|
+
getActiveProvider,
|
|
35
|
+
getRegisteredProvider,
|
|
36
|
+
registerProvider,
|
|
37
|
+
resetActiveProvider,
|
|
38
|
+
setActiveProvider
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/provider/registry.ts"],
|
|
4
|
+
"sourcesContent": ["import type { TelemetryProvider } from '../types'\nimport { NoopProvider } from './noop-provider'\n\n/**\n * The active provider is a PROCESS-WIDE singleton, stored on `globalThis` rather\n * than in a module variable. This is load-bearing: the worker bootstrap\n * esbuild-bundles the generated DI registry into a `.mjs` that can inline a\n * *private copy* of this module, while job handlers are dynamic-imported as\n * source and use the *source copy*. With module-level state, `initTelemetry()`\n * would set the provider on one copy while the handlers read a still-noop other\n * \u2014 so worker logs/spans would never reach the backend. Keying the state on a\n * global symbol makes every copy share one provider. Mirrors the framework's own\n * DI-registrar global.\n */\nconst GLOBAL_KEY = Symbol.for('@open-mercato/telemetry.providerRegistry')\n\ntype ProviderRegistry = {\n active: TelemetryProvider\n registered: Map<string, TelemetryProvider>\n}\n\nfunction store(): ProviderRegistry {\n const g = globalThis as unknown as Record<symbol, ProviderRegistry | undefined>\n let reg = g[GLOBAL_KEY]\n if (!reg) {\n reg = { active: new NoopProvider(), registered: new Map() }\n g[GLOBAL_KEY] = reg\n }\n return reg\n}\n\n/** Plug a custom backend before `initTelemetry()` (e.g. tests or a bespoke sink). */\nexport function registerProvider(provider: TelemetryProvider): void {\n store().registered.set(provider.name, provider)\n}\n\nexport function getRegisteredProvider(name: string): TelemetryProvider | undefined {\n return store().registered.get(name)\n}\n\nexport function getActiveProvider(): TelemetryProvider {\n return store().active\n}\n\n/** Set the active provider. Called by `initTelemetry()`. */\nexport function setActiveProvider(provider: TelemetryProvider): void {\n store().active = provider\n}\n\n/** Restore only the active provider while preserving custom registrations. */\nexport function clearActiveProvider(): void {\n store().active = new NoopProvider()\n}\n\n/** Test-only: restore the default no-op provider. */\nexport function resetActiveProvider(): void {\n const reg = store()\n clearActiveProvider()\n reg.registered.clear()\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAa7B,MAAM,aAAa,uBAAO,IAAI,0CAA0C;AAOxE,SAAS,QAA0B;AACjC,QAAM,IAAI;AACV,MAAI,MAAM,EAAE,UAAU;AACtB,MAAI,CAAC,KAAK;AACR,UAAM,EAAE,QAAQ,IAAI,aAAa,GAAG,YAAY,oBAAI,IAAI,EAAE;AAC1D,MAAE,UAAU,IAAI;AAAA,EAClB;AACA,SAAO;AACT;AAGO,SAAS,iBAAiB,UAAmC;AAClE,QAAM,EAAE,WAAW,IAAI,SAAS,MAAM,QAAQ;AAChD;AAEO,SAAS,sBAAsB,MAA6C;AACjF,SAAO,MAAM,EAAE,WAAW,IAAI,IAAI;AACpC;AAEO,SAAS,oBAAuC;AACrD,SAAO,MAAM,EAAE;AACjB;AAGO,SAAS,kBAAkB,UAAmC;AACnE,QAAM,EAAE,SAAS;AACnB;AAGO,SAAS,sBAA4B;AAC1C,QAAM,EAAE,SAAS,IAAI,aAAa;AACpC;AAGO,SAAS,sBAA4B;AAC1C,QAAM,MAAM,MAAM;AAClB,sBAAoB;AACpB,MAAI,WAAW,MAAM;AACvB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function isThenable(value) {
|
|
2
|
+
return value != null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
|
|
3
|
+
}
|
|
4
|
+
function runSpan(span, fn) {
|
|
5
|
+
let result;
|
|
6
|
+
try {
|
|
7
|
+
result = fn(span);
|
|
8
|
+
} catch (error) {
|
|
9
|
+
span.recordException(error);
|
|
10
|
+
span.setStatus("error");
|
|
11
|
+
span.end();
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
if (isThenable(result)) {
|
|
15
|
+
return result.then(
|
|
16
|
+
(value) => {
|
|
17
|
+
span.end();
|
|
18
|
+
return value;
|
|
19
|
+
},
|
|
20
|
+
(error) => {
|
|
21
|
+
span.recordException(error);
|
|
22
|
+
span.setStatus("error");
|
|
23
|
+
span.end();
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
span.end();
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
runSpan
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=run-span.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/provider/run-span.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Span } from '../types'\n\nfunction isThenable(value: unknown): value is Promise<unknown> {\n return (\n value != null &&\n (typeof value === 'object' || typeof value === 'function') &&\n typeof (value as { then?: unknown }).then === 'function'\n )\n}\n\n/**\n * Run `fn(span)` and end `span` when it settles \u2014 synchronously for sync\n * returns, on resolve/reject for promises. Exceptions (sync throw or rejected\n * promise) are recorded on the span and re-thrown. Shared by the console and\n * OTLP providers so async spans get correct durations.\n */\nexport function runSpan<T>(span: Span, fn: (span: Span) => T): T {\n let result: T\n try {\n result = fn(span)\n } catch (error) {\n span.recordException(error)\n span.setStatus('error')\n span.end()\n throw error\n }\n\n if (isThenable(result)) {\n return result.then(\n (value) => {\n span.end()\n return value\n },\n (error) => {\n span.recordException(error)\n span.setStatus('error')\n span.end()\n throw error\n },\n ) as T\n }\n\n span.end()\n return result\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,WAAW,OAA2C;AAC7D,SACE,SAAS,SACR,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;AAQO,SAAS,QAAW,MAAY,IAA0B;AAC/D,MAAI;AACJ,MAAI;AACF,aAAS,GAAG,IAAI;AAAA,EAClB,SAAS,OAAO;AACd,SAAK,gBAAgB,KAAK;AAC1B,SAAK,UAAU,OAAO;AACtB,SAAK,IAAI;AACT,UAAM;AAAA,EACR;AAEA,MAAI,WAAW,MAAM,GAAG;AACtB,WAAO,OAAO;AAAA,MACZ,CAAC,UAAU;AACT,aAAK,IAAI;AACT,eAAO;AAAA,MACT;AAAA,MACA,CAAC,UAAU;AACT,aAAK,gBAAgB,KAAK;AAC1B,aAAK,UAAU,OAAO;AACtB,aAAK,IAAI;AACT,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,OAAK,IAAI;AACT,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=types.js.map
|
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
const base = require('../../jest.config.base.cjs')
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
...base,
|
|
6
|
+
testEnvironment: 'node',
|
|
7
|
+
watchman: false,
|
|
8
|
+
rootDir: '.',
|
|
9
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
10
|
+
transform: {
|
|
11
|
+
'^.+\\.(t|j)sx?$': [
|
|
12
|
+
'<rootDir>/../../scripts/jest-mikroorm-transformer.cjs',
|
|
13
|
+
{
|
|
14
|
+
tsconfig: {
|
|
15
|
+
jsx: 'react-jsx',
|
|
16
|
+
rootDir: '.',
|
|
17
|
+
ignoreDeprecations: '6.0',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
transformIgnorePatterns: [
|
|
23
|
+
'node_modules/(?!(@mikro-orm|kysely)/)',
|
|
24
|
+
],
|
|
25
|
+
testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
|
|
26
|
+
passWithNoTests: true,
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-mercato/telemetry",
|
|
3
|
+
"version": "0.6.7-develop.6834.1.e76f4b8cbc",
|
|
4
|
+
"description": "Vendor-neutral spans, metrics, and error reporting backed by a pluggable OTLP provider. Off by default; the telemetry runtime is loaded only when explicitly enabled.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node build.mjs",
|
|
9
|
+
"watch": "node watch.mjs",
|
|
10
|
+
"test": "jest --config jest.config.cjs",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./*": {
|
|
19
|
+
"types": [
|
|
20
|
+
"./src/*.ts"
|
|
21
|
+
],
|
|
22
|
+
"default": "./dist/*.js"
|
|
23
|
+
},
|
|
24
|
+
"./*/*": {
|
|
25
|
+
"types": [
|
|
26
|
+
"./src/*/*.ts"
|
|
27
|
+
],
|
|
28
|
+
"default": "./dist/*/*.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@open-mercato/shared": "0.6.7-develop.6834.1.e76f4b8cbc"
|
|
33
|
+
},
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"@opentelemetry/api": "^1.9.1",
|
|
36
|
+
"@opentelemetry/api-logs": "^0.220.0",
|
|
37
|
+
"@opentelemetry/core": "^2.9.0",
|
|
38
|
+
"@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
|
|
39
|
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
|
|
40
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
|
41
|
+
"@opentelemetry/instrumentation": "^0.220.0",
|
|
42
|
+
"@opentelemetry/instrumentation-pg": "^0.71.0",
|
|
43
|
+
"@opentelemetry/instrumentation-undici": "^0.29.0",
|
|
44
|
+
"@opentelemetry/resources": "^2.9.0",
|
|
45
|
+
"@opentelemetry/sdk-logs": "^0.220.0",
|
|
46
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
47
|
+
"@opentelemetry/sdk-node": "^0.220.0",
|
|
48
|
+
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
|
49
|
+
"@opentelemetry/semantic-conventions": "^1.41.1"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
53
|
+
"@types/jest": "^30.0.0",
|
|
54
|
+
"@types/node": "^26.1.2",
|
|
55
|
+
"jest": "^30.4.2",
|
|
56
|
+
"ts-jest": "^29.4.12",
|
|
57
|
+
"typescript": "7.0.2"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "https://github.com/open-mercato/open-mercato",
|
|
65
|
+
"directory": "packages/telemetry"
|
|
66
|
+
},
|
|
67
|
+
"stableVersion": "0.6.6"
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isTelemetryBackendEnabled } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
4
|
+
|
|
5
|
+
const repoRoot = path.resolve(__dirname, '../../../..')
|
|
6
|
+
|
|
7
|
+
const runtimeHosts = [
|
|
8
|
+
'apps/mercato/src/instrumentation.ts',
|
|
9
|
+
'apps/mercato/src/app/api/[...slug]/route.ts',
|
|
10
|
+
'packages/create-app/template/src/instrumentation.ts',
|
|
11
|
+
'packages/create-app/template/src/app/api/[...slug]/route.ts',
|
|
12
|
+
'packages/cli/src/bin.ts',
|
|
13
|
+
'packages/queue/src/tracing.ts',
|
|
14
|
+
'packages/queue/src/strategies/async.ts',
|
|
15
|
+
'packages/queue/src/worker/runner.ts',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
describe('default-unloaded host wiring', () => {
|
|
19
|
+
const originalBackend = process.env.TELEMETRY_BACKEND
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
delete process.env.TELEMETRY_BACKEND
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterAll(() => {
|
|
26
|
+
if (originalBackend === undefined) delete process.env.TELEMETRY_BACKEND
|
|
27
|
+
else process.env.TELEMETRY_BACKEND = originalBackend
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
[undefined, false],
|
|
32
|
+
['', false],
|
|
33
|
+
['noop', false],
|
|
34
|
+
['unknown', false],
|
|
35
|
+
['console', true],
|
|
36
|
+
['otlp', true],
|
|
37
|
+
['signoz', true],
|
|
38
|
+
['newrelic', true],
|
|
39
|
+
])('resolves backend %s to enabled=%s', (raw, expected) => {
|
|
40
|
+
expect(isTelemetryBackendEnabled(raw)).toBe(expected)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it.each(runtimeHosts)('%s has no static telemetry runtime import', (relativePath) => {
|
|
44
|
+
const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
|
45
|
+
expect(source).not.toMatch(/\bfrom\s+['"]@open-mercato\/telemetry(?:['"/])/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('Next config imports only the runtime-free telemetry config entrypoint', () => {
|
|
49
|
+
for (const relativePath of [
|
|
50
|
+
'apps/mercato/next.config.ts',
|
|
51
|
+
'packages/create-app/template/next.config.ts',
|
|
52
|
+
]) {
|
|
53
|
+
const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
|
54
|
+
expect(source).toContain("from '@open-mercato/telemetry/nextjs-config'")
|
|
55
|
+
expect(source).not.toContain("from '@open-mercato/telemetry/nextjs'")
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getLoggerExtension, resetLoggerExtension } from '@open-mercato/shared/lib/logger'
|
|
2
|
+
import {
|
|
3
|
+
getTelemetryRuntime,
|
|
4
|
+
resetTelemetryRuntime,
|
|
5
|
+
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
6
|
+
import { initTelemetry, resetTelemetryInit } from '../init'
|
|
7
|
+
import {
|
|
8
|
+
getActiveProvider,
|
|
9
|
+
registerProvider,
|
|
10
|
+
resetActiveProvider,
|
|
11
|
+
} from '../provider/registry'
|
|
12
|
+
import { resetTelemetryEnvCache } from '../env'
|
|
13
|
+
import type { TelemetryProvider } from '../types'
|
|
14
|
+
import { NOOP_SPAN } from '../provider/noop-provider'
|
|
15
|
+
|
|
16
|
+
function provider(name = 'noop'): TelemetryProvider {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
supports: [],
|
|
20
|
+
start: jest.fn(async () => {}),
|
|
21
|
+
shutdown: jest.fn(async () => {}),
|
|
22
|
+
runInSpan: (_name, _options, fn) => fn(NOOP_SPAN),
|
|
23
|
+
activeSpan: () => undefined,
|
|
24
|
+
activeTraceContext: () => undefined,
|
|
25
|
+
inject: () => {},
|
|
26
|
+
runInRemoteSpan: (_carrier, _name, _options, fn) => fn(NOOP_SPAN),
|
|
27
|
+
emitLog: () => {},
|
|
28
|
+
recordMetric: () => {},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('telemetry explicit opt-in boundary', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
delete process.env.TELEMETRY_BACKEND
|
|
35
|
+
resetTelemetryEnvCache()
|
|
36
|
+
resetTelemetryInit()
|
|
37
|
+
resetActiveProvider()
|
|
38
|
+
resetLoggerExtension()
|
|
39
|
+
resetTelemetryRuntime()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not resolve a custom provider or register runtime hooks while off', async () => {
|
|
43
|
+
const customNoop = provider()
|
|
44
|
+
registerProvider(customNoop)
|
|
45
|
+
|
|
46
|
+
await initTelemetry()
|
|
47
|
+
|
|
48
|
+
expect(customNoop.start).not.toHaveBeenCalled()
|
|
49
|
+
expect(getActiveProvider().name).toBe('noop')
|
|
50
|
+
expect(getLoggerExtension()).toBeUndefined()
|
|
51
|
+
expect(getTelemetryRuntime()).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('can initialize after dotenv sets an enabled backend following an off call', async () => {
|
|
55
|
+
const customConsole = provider('console')
|
|
56
|
+
registerProvider(customConsole)
|
|
57
|
+
|
|
58
|
+
await initTelemetry()
|
|
59
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
60
|
+
await initTelemetry()
|
|
61
|
+
|
|
62
|
+
expect(customConsole.start).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(getActiveProvider()).toBe(customConsole)
|
|
64
|
+
expect(getLoggerExtension()).toBeDefined()
|
|
65
|
+
expect(getTelemetryRuntime()).toBeDefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('initializes an explicitly registered custom backend', async () => {
|
|
69
|
+
const customProvider = provider('custom-observability')
|
|
70
|
+
registerProvider(customProvider)
|
|
71
|
+
process.env.TELEMETRY_BACKEND = 'custom-observability'
|
|
72
|
+
|
|
73
|
+
await initTelemetry()
|
|
74
|
+
|
|
75
|
+
expect(customProvider.start).toHaveBeenCalledTimes(1)
|
|
76
|
+
expect(getActiveProvider()).toBe(customProvider)
|
|
77
|
+
expect(getLoggerExtension()).toBeDefined()
|
|
78
|
+
expect(getTelemetryRuntime()).toBeDefined()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isOtlpBackend, readTelemetryEnv, resetTelemetryEnvCache } from '../env'
|
|
2
|
+
|
|
3
|
+
const SAVED = { ...process.env }
|
|
4
|
+
|
|
5
|
+
function setEnv(vars: Record<string, string | undefined>): void {
|
|
6
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
7
|
+
if (v === undefined) delete process.env[k]
|
|
8
|
+
else process.env[k] = v
|
|
9
|
+
}
|
|
10
|
+
resetTelemetryEnvCache()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
process.env = { ...SAVED }
|
|
15
|
+
resetTelemetryEnvCache()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('readTelemetryEnv', () => {
|
|
19
|
+
it('defaults to the noop backend (off) when TELEMETRY_BACKEND is unset', () => {
|
|
20
|
+
setEnv({ TELEMETRY_BACKEND: undefined })
|
|
21
|
+
const env = readTelemetryEnv()
|
|
22
|
+
expect(env.backend).toBe('noop')
|
|
23
|
+
expect(env.enabled).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('treats an unknown backend as noop', () => {
|
|
27
|
+
setEnv({ TELEMETRY_BACKEND: 'datadog-lol' })
|
|
28
|
+
expect(readTelemetryEnv().backend).toBe('noop')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('accepts signoz/console and marks them enabled', () => {
|
|
32
|
+
setEnv({ TELEMETRY_BACKEND: 'signoz' })
|
|
33
|
+
expect(readTelemetryEnv()).toMatchObject({ backend: 'signoz', enabled: true })
|
|
34
|
+
setEnv({ TELEMETRY_BACKEND: 'console' })
|
|
35
|
+
expect(readTelemetryEnv()).toMatchObject({ backend: 'console', enabled: true })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('accepts newrelic/otlp as OTLP backends (same seam, vendor by endpoint)', () => {
|
|
39
|
+
setEnv({ TELEMETRY_BACKEND: 'newrelic' })
|
|
40
|
+
expect(readTelemetryEnv()).toMatchObject({ backend: 'newrelic', enabled: true })
|
|
41
|
+
setEnv({ TELEMETRY_BACKEND: 'otlp' })
|
|
42
|
+
expect(readTelemetryEnv()).toMatchObject({ backend: 'otlp', enabled: true })
|
|
43
|
+
expect(isOtlpBackend('newrelic')).toBe(true)
|
|
44
|
+
expect(isOtlpBackend('otlp')).toBe(true)
|
|
45
|
+
expect(isOtlpBackend('signoz')).toBe(true)
|
|
46
|
+
expect(isOtlpBackend('console')).toBe(false)
|
|
47
|
+
expect(isOtlpBackend('noop')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('parses sampling ratio and falls back / clamps invalid values', () => {
|
|
51
|
+
setEnv({ TELEMETRY_SAMPLING_RATIO: '0.25', NODE_ENV: 'development' })
|
|
52
|
+
expect(readTelemetryEnv().samplingRatio).toBe(0.25)
|
|
53
|
+
setEnv({ TELEMETRY_SAMPLING_RATIO: '5', NODE_ENV: 'development' })
|
|
54
|
+
expect(readTelemetryEnv().samplingRatio).toBe(1.0) // out of range -> dev default
|
|
55
|
+
setEnv({ TELEMETRY_SAMPLING_RATIO: undefined, NODE_ENV: 'development' })
|
|
56
|
+
expect(readTelemetryEnv().samplingRatio).toBe(1.0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('trust-inbound-trace defaults off (root-per-request), explicit values parse', () => {
|
|
60
|
+
setEnv({ TELEMETRY_TRUST_INBOUND_TRACE: undefined })
|
|
61
|
+
expect(readTelemetryEnv().trustInboundTrace).toBe(false)
|
|
62
|
+
setEnv({ TELEMETRY_TRUST_INBOUND_TRACE: 'true' })
|
|
63
|
+
expect(readTelemetryEnv().trustInboundTrace).toBe(true)
|
|
64
|
+
setEnv({ TELEMETRY_TRUST_INBOUND_TRACE: '1' })
|
|
65
|
+
expect(readTelemetryEnv().trustInboundTrace).toBe(true)
|
|
66
|
+
setEnv({ TELEMETRY_TRUST_INBOUND_TRACE: 'false' })
|
|
67
|
+
expect(readTelemetryEnv().trustInboundTrace).toBe(false)
|
|
68
|
+
setEnv({ TELEMETRY_TRUST_INBOUND_TRACE: 'garbage' })
|
|
69
|
+
expect(readTelemetryEnv().trustInboundTrace).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createLogger,
|
|
3
|
+
resetLogLevelCache,
|
|
4
|
+
resetLoggerExtension,
|
|
5
|
+
resetLoggerRegistry,
|
|
6
|
+
} from '@open-mercato/shared/lib/logger'
|
|
7
|
+
import { resetTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
8
|
+
import { initTelemetry, resetTelemetryInit } from '../init'
|
|
9
|
+
import { registerProvider, resetActiveProvider } from '../provider/registry'
|
|
10
|
+
import { resetTelemetryEnvCache } from '../env'
|
|
11
|
+
import { NOOP_SPAN } from '../provider/noop-provider'
|
|
12
|
+
import type { LogRecord, TelemetryProvider } from '../types'
|
|
13
|
+
|
|
14
|
+
function recordingProvider(logs: LogRecord[]): TelemetryProvider {
|
|
15
|
+
return {
|
|
16
|
+
name: 'console',
|
|
17
|
+
supports: ['logs'],
|
|
18
|
+
async start() {},
|
|
19
|
+
async shutdown() {},
|
|
20
|
+
runInSpan: (_name, _options, fn) => fn(NOOP_SPAN),
|
|
21
|
+
activeSpan: () => undefined,
|
|
22
|
+
activeTraceContext: () => undefined,
|
|
23
|
+
inject: () => {},
|
|
24
|
+
runInRemoteSpan: (_carrier, _name, _options, fn) => fn(NOOP_SPAN),
|
|
25
|
+
emitLog: (record) => logs.push(record),
|
|
26
|
+
recordMetric: () => {},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('unified logger telemetry export', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
33
|
+
process.env.OM_LOG_LEVEL = 'warn'
|
|
34
|
+
resetLogLevelCache()
|
|
35
|
+
resetLoggerRegistry()
|
|
36
|
+
resetLoggerExtension()
|
|
37
|
+
resetTelemetryRuntime()
|
|
38
|
+
resetTelemetryInit()
|
|
39
|
+
resetActiveProvider()
|
|
40
|
+
resetTelemetryEnvCache()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
delete process.env.TELEMETRY_BACKEND
|
|
45
|
+
delete process.env.OM_LOG_LEVEL
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('uses the shared level gate for local and remote output', async () => {
|
|
49
|
+
const logs: LogRecord[] = []
|
|
50
|
+
registerProvider(recordingProvider(logs))
|
|
51
|
+
await initTelemetry()
|
|
52
|
+
const logger = createLogger('orders')
|
|
53
|
+
|
|
54
|
+
logger.debug('quiet')
|
|
55
|
+
logger.info('quiet')
|
|
56
|
+
logger.warn('visible')
|
|
57
|
+
logger.error('failed', { err: new Error('boom') })
|
|
58
|
+
|
|
59
|
+
expect(logs.map((record) => record.level)).toEqual(['warn', 'error'])
|
|
60
|
+
expect(logs.map((record) => record.message)).toEqual(['visible', 'failed'])
|
|
61
|
+
expect(logs[1].error?.message).toBe('boom')
|
|
62
|
+
expect(logs[1].attributes?.['logger.name']).toBe('orders')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: registerTelemetryForNextjs()'s SIGTERM/SIGINT flush handler used
|
|
7
|
+
* to only call shutdownTelemetry() without re-raising the signal. A signal
|
|
8
|
+
* listener suppresses Node's default termination, so a web process that
|
|
9
|
+
* received SIGTERM on deploy flushed telemetry and then stayed alive until the
|
|
10
|
+
* orchestrator force-killed it. The handler must re-raise after the flush so
|
|
11
|
+
* the process still terminates.
|
|
12
|
+
*
|
|
13
|
+
* Runs in a real child process: only a real signal against a real event loop
|
|
14
|
+
* proves the process dies — in-process listener assertions cannot.
|
|
15
|
+
*/
|
|
16
|
+
const distNextjs = path.resolve(__dirname, '../../dist/nextjs.js')
|
|
17
|
+
const pkgRoot = path.resolve(__dirname, '../..')
|
|
18
|
+
|
|
19
|
+
const CHILD_SCRIPT = `
|
|
20
|
+
const { registerTelemetryForNextjs } = await import('@open-mercato/telemetry/nextjs')
|
|
21
|
+
await registerTelemetryForNextjs()
|
|
22
|
+
setInterval(() => {}, 1000) // keep the event loop alive, like a server would
|
|
23
|
+
console.log('READY')
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
// The build output must exist (the child imports the compiled helper). Runs in
|
|
27
|
+
// CI after build:packages; skip locally when the package hasn't been built yet.
|
|
28
|
+
const maybe = fs.existsSync(distNextjs) ? describe : describe.skip
|
|
29
|
+
|
|
30
|
+
maybe('registerTelemetryForNextjs shutdown semantics', () => {
|
|
31
|
+
it('a SIGTERM after registration still terminates the process', async () => {
|
|
32
|
+
const scriptFile = path.join(pkgRoot, `.nextjs-shutdown-probe.${process.pid}.mjs`)
|
|
33
|
+
fs.writeFileSync(scriptFile, CHILD_SCRIPT)
|
|
34
|
+
try {
|
|
35
|
+
const child = spawn(process.execPath, [scriptFile], {
|
|
36
|
+
cwd: pkgRoot,
|
|
37
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
|
+
env: { ...process.env, TELEMETRY_BACKEND: 'console' },
|
|
39
|
+
})
|
|
40
|
+
await new Promise<void>((resolve, reject) => {
|
|
41
|
+
const bailout = setTimeout(() => reject(new Error('child never printed READY')), 15_000)
|
|
42
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
43
|
+
if (chunk.toString().includes('READY')) {
|
|
44
|
+
clearTimeout(bailout)
|
|
45
|
+
resolve()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
child.on('error', reject)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
child.kill('SIGTERM')
|
|
52
|
+
|
|
53
|
+
const outcome = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
|
54
|
+
(resolve, reject) => {
|
|
55
|
+
const bailout = setTimeout(() => {
|
|
56
|
+
child.kill('SIGKILL')
|
|
57
|
+
reject(new Error('process stayed alive after SIGTERM — the flush handler suppressed termination'))
|
|
58
|
+
}, 10_000)
|
|
59
|
+
child.on('exit', (code, signal) => {
|
|
60
|
+
clearTimeout(bailout)
|
|
61
|
+
resolve({ code, signal })
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
// The re-raised signal terminates the process with default semantics.
|
|
66
|
+
expect(outcome.signal).toBe('SIGTERM')
|
|
67
|
+
} finally {
|
|
68
|
+
fs.rmSync(scriptFile, { force: true })
|
|
69
|
+
}
|
|
70
|
+
}, 35_000)
|
|
71
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { telemetryServerExternalPackages, recordHttpDuration, registerTelemetryForNextjs } from '../nextjs'
|
|
4
|
+
import { resetTelemetryInit } from '../init'
|
|
5
|
+
import { registerProvider, resetActiveProvider } from '../provider/registry'
|
|
6
|
+
import { resetTelemetryEnvCache } from '../env'
|
|
7
|
+
import { runSpan } from '../provider/run-span'
|
|
8
|
+
import type { MetricPoint, Span, SpanOptions, TelemetryProvider } from '../types'
|
|
9
|
+
|
|
10
|
+
class RecordingSpan implements Span {
|
|
11
|
+
setAttribute(): void {}
|
|
12
|
+
setAttributes(): void {}
|
|
13
|
+
recordException(): void {}
|
|
14
|
+
setStatus(): void {}
|
|
15
|
+
end(): void {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function otelSpecifiersInSource(): string[] {
|
|
19
|
+
// Scan every source file except nextjs.ts itself (it contains the list under
|
|
20
|
+
// test) and the tests, so the expected set is derived from what the provider
|
|
21
|
+
// actually imports rather than a second hand-maintained copy.
|
|
22
|
+
const specifiers = new Set<string>()
|
|
23
|
+
const visit = (dir: string) => {
|
|
24
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
if (entry.name === '__tests__') continue
|
|
26
|
+
const full = path.join(dir, entry.name)
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
visit(full)
|
|
29
|
+
} else if (entry.name.endsWith('.ts') && full !== path.resolve(__dirname, '../nextjs-config.ts')) {
|
|
30
|
+
for (const match of fs.readFileSync(full, 'utf8').matchAll(/['"](@opentelemetry\/[a-z0-9-]+)['"]/g)) {
|
|
31
|
+
specifiers.add(match[1])
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
visit(path.resolve(__dirname, '..'))
|
|
37
|
+
return [...specifiers].sort()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordingProvider() {
|
|
41
|
+
const metrics: MetricPoint[] = []
|
|
42
|
+
let starts = 0
|
|
43
|
+
const span = new RecordingSpan()
|
|
44
|
+
const provider: TelemetryProvider = {
|
|
45
|
+
name: 'console',
|
|
46
|
+
supports: ['traces', 'metrics', 'logs', 'errors'],
|
|
47
|
+
async start() {
|
|
48
|
+
starts += 1
|
|
49
|
+
},
|
|
50
|
+
async shutdown() {},
|
|
51
|
+
runInSpan<T>(_name: string, _options: SpanOptions, fn: (s: Span) => T): T {
|
|
52
|
+
return runSpan(span, fn)
|
|
53
|
+
},
|
|
54
|
+
activeSpan: () => undefined,
|
|
55
|
+
inject: () => {},
|
|
56
|
+
runInRemoteSpan<T>(_carrier, _name: string, _options: SpanOptions, fn: (s: Span) => T): T {
|
|
57
|
+
return runSpan(span, fn)
|
|
58
|
+
},
|
|
59
|
+
emitLog: () => {},
|
|
60
|
+
recordMetric: (point) => metrics.push(point),
|
|
61
|
+
}
|
|
62
|
+
return { provider, metrics, startCount: () => starts }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('telemetry/nextjs helpers', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
resetActiveProvider()
|
|
68
|
+
resetTelemetryInit()
|
|
69
|
+
resetTelemetryEnvCache()
|
|
70
|
+
delete process.env.TELEMETRY_BACKEND
|
|
71
|
+
delete process.env.NEXT_RUNTIME
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('externals list is the full OTEL set the provider loads (no partial-copy footgun)', () => {
|
|
75
|
+
// Every @opentelemetry/* package the provider imports must be listed, or the
|
|
76
|
+
// bundler re-bundles a patched module and telemetry silently emits nothing.
|
|
77
|
+
// Set-equality both ways: a new provider import without a list entry fails,
|
|
78
|
+
// and so does a stale list entry nothing imports anymore.
|
|
79
|
+
expect([...telemetryServerExternalPackages].sort()).toEqual(otelSpecifiersInSource())
|
|
80
|
+
expect(telemetryServerExternalPackages.every((p) => p.startsWith('@opentelemetry/'))).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('recordHttpDuration emits the semconv histogram; error.type only on 5xx', async () => {
|
|
84
|
+
const { provider, metrics } = recordingProvider()
|
|
85
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
86
|
+
registerProvider(provider)
|
|
87
|
+
await registerTelemetryForNextjs()
|
|
88
|
+
|
|
89
|
+
recordHttpDuration('GET', '/api/[...slug]', 200, Date.now())
|
|
90
|
+
recordHttpDuration('POST', '/api/[...slug]', 500, Date.now())
|
|
91
|
+
|
|
92
|
+
const points = metrics.filter((m) => m.name === 'http.server.request.duration')
|
|
93
|
+
expect(points).toHaveLength(2)
|
|
94
|
+
expect(points[0].kind).toBe('histogram')
|
|
95
|
+
expect(points[0].unit).toBe('s')
|
|
96
|
+
expect(points[0].labels).toMatchObject({
|
|
97
|
+
'http.request.method': 'GET',
|
|
98
|
+
'http.route': '/api/[...slug]',
|
|
99
|
+
'http.response.status_code': 200,
|
|
100
|
+
})
|
|
101
|
+
expect(points[0].labels?.['error.type']).toBeUndefined()
|
|
102
|
+
expect(points[1].labels?.['error.type']).toBe('500')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('registerTelemetryForNextjs is a safe no-op when telemetry is off', async () => {
|
|
106
|
+
await expect(registerTelemetryForNextjs()).resolves.toBeUndefined()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('skips initialization on the edge runtime (NodeSDK is Node-only)', async () => {
|
|
110
|
+
const { provider, startCount } = recordingProvider()
|
|
111
|
+
registerProvider(provider)
|
|
112
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
113
|
+
|
|
114
|
+
process.env.NEXT_RUNTIME = 'edge'
|
|
115
|
+
await registerTelemetryForNextjs()
|
|
116
|
+
expect(startCount()).toBe(0)
|
|
117
|
+
|
|
118
|
+
process.env.NEXT_RUNTIME = 'nodejs'
|
|
119
|
+
await registerTelemetryForNextjs()
|
|
120
|
+
expect(startCount()).toBe(1)
|
|
121
|
+
})
|
|
122
|
+
})
|