@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.
Files changed (82) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +71 -0
  3. package/README.md +164 -0
  4. package/build.mjs +10 -0
  5. package/dist/env.js +44 -0
  6. package/dist/env.js.map +7 -0
  7. package/dist/facade/context.js +14 -0
  8. package/dist/facade/context.js.map +7 -0
  9. package/dist/facade/http.js +18 -0
  10. package/dist/facade/http.js.map +7 -0
  11. package/dist/facade/logger-bridge.js +40 -0
  12. package/dist/facade/logger-bridge.js.map +7 -0
  13. package/dist/facade/meter.js +16 -0
  14. package/dist/facade/meter.js.map +7 -0
  15. package/dist/facade/propagation.js +20 -0
  16. package/dist/facade/propagation.js.map +7 -0
  17. package/dist/facade/redact.js +21 -0
  18. package/dist/facade/redact.js.map +7 -0
  19. package/dist/facade/report-error.js +26 -0
  20. package/dist/facade/report-error.js.map +7 -0
  21. package/dist/facade/serialize.js +35 -0
  22. package/dist/facade/serialize.js.map +7 -0
  23. package/dist/facade/tracer.js +21 -0
  24. package/dist/facade/tracer.js.map +7 -0
  25. package/dist/index.js +21 -0
  26. package/dist/index.js.map +7 -0
  27. package/dist/init.js +98 -0
  28. package/dist/init.js.map +7 -0
  29. package/dist/nextjs-config.js +21 -0
  30. package/dist/nextjs-config.js.map +7 -0
  31. package/dist/nextjs.js +30 -0
  32. package/dist/nextjs.js.map +7 -0
  33. package/dist/provider/console-provider.js +70 -0
  34. package/dist/provider/console-provider.js.map +7 -0
  35. package/dist/provider/noop-provider.js +45 -0
  36. package/dist/provider/noop-provider.js.map +7 -0
  37. package/dist/provider/otlp-provider.js +212 -0
  38. package/dist/provider/otlp-provider.js.map +7 -0
  39. package/dist/provider/registry.js +40 -0
  40. package/dist/provider/registry.js.map +7 -0
  41. package/dist/provider/run-span.js +34 -0
  42. package/dist/provider/run-span.js.map +7 -0
  43. package/dist/types.js +1 -0
  44. package/dist/types.js.map +7 -0
  45. package/jest.config.cjs +27 -0
  46. package/package.json +68 -0
  47. package/src/__tests__/default-unloaded.test.ts +58 -0
  48. package/src/__tests__/env-load-order.test.ts +80 -0
  49. package/src/__tests__/env.test.ts +72 -0
  50. package/src/__tests__/logger-level.test.ts +64 -0
  51. package/src/__tests__/nextjs-shutdown.test.ts +71 -0
  52. package/src/__tests__/nextjs.test.ts +122 -0
  53. package/src/__tests__/otlp-integration.test.ts +264 -0
  54. package/src/__tests__/pg-instrumentation.test.ts +88 -0
  55. package/src/__tests__/pg-pii-config.test.ts +14 -0
  56. package/src/__tests__/redact.test.ts +92 -0
  57. package/src/__tests__/registry.test.ts +77 -0
  58. package/src/__tests__/run-span.test.ts +56 -0
  59. package/src/__tests__/serialize.test.ts +47 -0
  60. package/src/__tests__/telemetry.test.ts +172 -0
  61. package/src/env.ts +71 -0
  62. package/src/facade/context.ts +17 -0
  63. package/src/facade/http.ts +25 -0
  64. package/src/facade/logger-bridge.ts +50 -0
  65. package/src/facade/meter.ts +20 -0
  66. package/src/facade/propagation.ts +31 -0
  67. package/src/facade/redact.ts +54 -0
  68. package/src/facade/report-error.ts +41 -0
  69. package/src/facade/serialize.ts +46 -0
  70. package/src/facade/tracer.ts +25 -0
  71. package/src/index.ts +30 -0
  72. package/src/init.ts +134 -0
  73. package/src/nextjs-config.ts +22 -0
  74. package/src/nextjs.ts +47 -0
  75. package/src/provider/console-provider.ts +87 -0
  76. package/src/provider/noop-provider.ts +53 -0
  77. package/src/provider/otlp-provider.ts +316 -0
  78. package/src/provider/registry.ts +60 -0
  79. package/src/provider/run-span.ts +45 -0
  80. package/src/types.ts +108 -0
  81. package/tsconfig.json +9 -0
  82. 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
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -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
+ })