autotel 2.23.0 → 2.24.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.
Files changed (84) hide show
  1. package/bin/intent.js +6 -0
  2. package/dist/auto.cjs +2 -2
  3. package/dist/auto.js +1 -1
  4. package/dist/{chunk-GY4CRZSV.js → chunk-2D74YR4Z.js} +3 -3
  5. package/dist/{chunk-GY4CRZSV.js.map → chunk-2D74YR4Z.js.map} +1 -1
  6. package/dist/{chunk-724XLWR3.js → chunk-47KW5CV7.js} +87 -4
  7. package/dist/chunk-47KW5CV7.js.map +1 -0
  8. package/dist/{chunk-3ZFDJJWZ.cjs → chunk-AXNPD6XW.cjs} +26 -26
  9. package/dist/{chunk-3ZFDJJWZ.cjs.map → chunk-AXNPD6XW.cjs.map} +1 -1
  10. package/dist/{chunk-BSZP4URK.cjs → chunk-CLR5RQW4.cjs} +5 -5
  11. package/dist/{chunk-BSZP4URK.cjs.map → chunk-CLR5RQW4.cjs.map} +1 -1
  12. package/dist/{chunk-AXFWWJF3.cjs → chunk-CSG2D3BZ.cjs} +7 -7
  13. package/dist/{chunk-AXFWWJF3.cjs.map → chunk-CSG2D3BZ.cjs.map} +1 -1
  14. package/dist/{chunk-MSUHW2I4.cjs → chunk-CZR7PJUZ.cjs} +13 -13
  15. package/dist/{chunk-MSUHW2I4.cjs.map → chunk-CZR7PJUZ.cjs.map} +1 -1
  16. package/dist/{chunk-IPKXURBW.js → chunk-GSZJOYFF.js} +3 -3
  17. package/dist/{chunk-IPKXURBW.js.map → chunk-GSZJOYFF.js.map} +1 -1
  18. package/dist/{chunk-WCIIFRGL.js → chunk-IJMW6CTV.js} +3 -3
  19. package/dist/{chunk-WCIIFRGL.js.map → chunk-IJMW6CTV.js.map} +1 -1
  20. package/dist/{chunk-HSEIUH7F.cjs → chunk-PDRIJURL.cjs} +86 -2
  21. package/dist/chunk-PDRIJURL.cjs.map +1 -0
  22. package/dist/{chunk-4RZ4JUBY.cjs → chunk-W6U2BUHU.cjs} +5 -5
  23. package/dist/{chunk-4RZ4JUBY.cjs.map → chunk-W6U2BUHU.cjs.map} +1 -1
  24. package/dist/{chunk-5XUEHX7J.js → chunk-Z3BQYTZE.js} +3 -3
  25. package/dist/{chunk-5XUEHX7J.js.map → chunk-Z3BQYTZE.js.map} +1 -1
  26. package/dist/{chunk-3AMR5XLZ.js → chunk-ZJTBAUXG.js} +3 -3
  27. package/dist/{chunk-3AMR5XLZ.js.map → chunk-ZJTBAUXG.js.map} +1 -1
  28. package/dist/decorators.cjs +2 -2
  29. package/dist/decorators.js +2 -2
  30. package/dist/enrichers.cjs +90 -0
  31. package/dist/enrichers.cjs.map +1 -0
  32. package/dist/enrichers.d.cts +23 -0
  33. package/dist/enrichers.d.ts +23 -0
  34. package/dist/enrichers.js +86 -0
  35. package/dist/enrichers.js.map +1 -0
  36. package/dist/event.cjs +5 -5
  37. package/dist/event.js +2 -2
  38. package/dist/functional.cjs +9 -9
  39. package/dist/functional.js +2 -2
  40. package/dist/index.cjs +63 -41
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.cts +10 -3
  43. package/dist/index.d.ts +10 -3
  44. package/dist/index.js +28 -11
  45. package/dist/index.js.map +1 -1
  46. package/dist/{init-BC5aN8bh.d.cts → init-Co9CWOY6.d.cts} +16 -0
  47. package/dist/{init-_FG4IbhF.d.ts → init-oLqNJTpj.d.ts} +16 -0
  48. package/dist/instrumentation.cjs +8 -8
  49. package/dist/instrumentation.js +1 -1
  50. package/dist/messaging.cjs +6 -6
  51. package/dist/messaging.js +3 -3
  52. package/dist/semantic-helpers.cjs +7 -7
  53. package/dist/semantic-helpers.js +3 -3
  54. package/dist/webhook.cjs +3 -3
  55. package/dist/webhook.js +2 -2
  56. package/dist/workflow-distributed.cjs +4 -4
  57. package/dist/workflow-distributed.js +2 -2
  58. package/dist/workflow.cjs +7 -7
  59. package/dist/workflow.js +3 -3
  60. package/dist/yaml-config.d.cts +1 -1
  61. package/dist/yaml-config.d.ts +1 -1
  62. package/package.json +45 -33
  63. package/skills/autotel-core/SKILL.md +162 -0
  64. package/skills/autotel-events/SKILL.md +88 -0
  65. package/skills/autotel-frameworks/SKILL.md +97 -0
  66. package/skills/autotel-instrumentation/SKILL.md +125 -0
  67. package/skills/autotel-request-logging/SKILL.md +100 -0
  68. package/skills/autotel-structured-errors/SKILL.md +130 -0
  69. package/src/enrichers.test.ts +150 -0
  70. package/src/enrichers.ts +145 -0
  71. package/src/index.ts +4 -0
  72. package/src/init-redactor.test.ts +8 -0
  73. package/src/init.ts +56 -0
  74. package/src/package-manifest.test.ts +24 -0
  75. package/src/posthog-logs.test.ts +115 -0
  76. package/src/posthog-logs.ts +77 -0
  77. package/src/redact-values.test.ts +76 -0
  78. package/src/redact-values.ts +27 -0
  79. package/src/request-logger.test.ts +14 -1
  80. package/src/request-logger.ts +11 -1
  81. package/src/structured-error.test.ts +30 -0
  82. package/src/structured-error.ts +11 -0
  83. package/dist/chunk-724XLWR3.js.map +0 -1
  84. package/dist/chunk-HSEIUH7F.cjs.map +0 -1
@@ -0,0 +1,145 @@
1
+ type Headers = Record<string, string | string[] | undefined>;
2
+
3
+ function get(headers: Headers, key: string): string | undefined {
4
+ const lower = key.toLowerCase();
5
+ for (const [k, v] of Object.entries(headers)) {
6
+ if (k.toLowerCase() === lower) {
7
+ return Array.isArray(v) ? v[0] : v;
8
+ }
9
+ }
10
+ return undefined;
11
+ }
12
+
13
+ // --- User Agent ---
14
+
15
+ export interface UserAgentAttributes {
16
+ 'user_agent.raw': string;
17
+ 'user_agent.browser'?: string;
18
+ 'user_agent.os'?: string;
19
+ 'user_agent.device'?: string;
20
+ }
21
+
22
+ const BROWSER_RE = /(Firefox|OPR|Edg|Chrome|Safari|MSIE|Trident)[\s/]?([\d.]*)/;
23
+ const OS_RE =
24
+ /(Windows NT|Mac OS X|Linux|Android|iPhone OS|iPad|CrOS)[\s]?([\d._]*)/;
25
+
26
+ function parseBrowser(ua: string): string | undefined {
27
+ const m = BROWSER_RE.exec(ua);
28
+ if (!m) return undefined;
29
+ const name =
30
+ m[1] === 'OPR'
31
+ ? 'Opera'
32
+ : m[1] === 'Edg'
33
+ ? 'Edge'
34
+ : m[1] === 'Trident'
35
+ ? 'IE'
36
+ : m[1];
37
+ return m[2] ? `${name} ${m[2]}` : name;
38
+ }
39
+
40
+ function parseOS(ua: string): string | undefined {
41
+ const m = OS_RE.exec(ua);
42
+ if (!m) return undefined;
43
+ const name =
44
+ m[1] === 'iPhone OS'
45
+ ? 'iOS'
46
+ : m[1] === 'Windows NT'
47
+ ? 'Windows'
48
+ : m[1] === 'Mac OS X'
49
+ ? 'macOS'
50
+ : m[1];
51
+ const ver = m[2]?.replaceAll('_', '.') || undefined;
52
+ return ver ? `${name} ${ver}` : name;
53
+ }
54
+
55
+ function parseDevice(ua: string): string | undefined {
56
+ if (/Mobi|Android.*Mobile|iPhone/.test(ua)) return 'mobile';
57
+ if (/iPad|Android(?!.*Mobile)|Tablet/.test(ua)) return 'tablet';
58
+ if (/Bot|Crawler|Spider|Lighthouse/i.test(ua)) return 'bot';
59
+ return 'desktop';
60
+ }
61
+
62
+ export function userAgent(headers: Headers): UserAgentAttributes | undefined {
63
+ const raw = get(headers, 'user-agent');
64
+ if (!raw) return undefined;
65
+
66
+ const attrs: UserAgentAttributes = { 'user_agent.raw': raw };
67
+ const browser = parseBrowser(raw);
68
+ if (browser) attrs['user_agent.browser'] = browser;
69
+ const os = parseOS(raw);
70
+ if (os) attrs['user_agent.os'] = os;
71
+ const device = parseDevice(raw);
72
+ if (device) attrs['user_agent.device'] = device;
73
+
74
+ return attrs;
75
+ }
76
+
77
+ // --- Geo ---
78
+
79
+ export interface GeoAttributes {
80
+ 'geo.country'?: string;
81
+ 'geo.region'?: string;
82
+ 'geo.city'?: string;
83
+ 'geo.latitude'?: string;
84
+ 'geo.longitude'?: string;
85
+ }
86
+
87
+ export function geo(headers: Headers): GeoAttributes | undefined {
88
+ const country =
89
+ get(headers, 'x-vercel-ip-country') ?? get(headers, 'cf-ipcountry');
90
+ const region = get(headers, 'x-vercel-ip-country-region');
91
+ const city = get(headers, 'x-vercel-ip-city');
92
+ const latitude = get(headers, 'x-vercel-ip-latitude');
93
+ const longitude = get(headers, 'x-vercel-ip-longitude');
94
+
95
+ if (!country && !region && !city && !latitude && !longitude) return undefined;
96
+
97
+ const attrs: GeoAttributes = {};
98
+ if (country) attrs['geo.country'] = country;
99
+ if (region) attrs['geo.region'] = region;
100
+ if (city) {
101
+ try {
102
+ attrs['geo.city'] = decodeURIComponent(city);
103
+ } catch {
104
+ attrs['geo.city'] = city;
105
+ }
106
+ }
107
+ if (latitude) attrs['geo.latitude'] = latitude;
108
+ if (longitude) attrs['geo.longitude'] = longitude;
109
+
110
+ return attrs;
111
+ }
112
+
113
+ // --- Request Size ---
114
+
115
+ export interface RequestSizeAttributes {
116
+ 'http.request.body.size'?: number;
117
+ 'http.response.body.size'?: number;
118
+ }
119
+
120
+ const DIGITS_RE = /^\d+$/;
121
+
122
+ function parseContentLength(value: string | undefined): number | undefined {
123
+ if (!value || !DIGITS_RE.test(value)) return undefined;
124
+ return Number(value);
125
+ }
126
+
127
+ export function requestSize(
128
+ requestHeaders: Headers,
129
+ responseHeaders?: Headers,
130
+ ): RequestSizeAttributes | undefined {
131
+ const reqLen = get(requestHeaders, 'content-length');
132
+ const resLen = responseHeaders
133
+ ? get(responseHeaders, 'content-length')
134
+ : undefined;
135
+
136
+ if (!reqLen && !resLen) return undefined;
137
+
138
+ const attrs: RequestSizeAttributes = {};
139
+ const reqBytes = parseContentLength(reqLen);
140
+ if (reqBytes !== undefined) attrs['http.request.body.size'] = reqBytes;
141
+ const resBytes = parseContentLength(resLen);
142
+ if (resBytes !== undefined) attrs['http.response.body.size'] = resBytes;
143
+
144
+ return Object.keys(attrs).length > 0 ? attrs : undefined;
145
+ }
package/src/index.ts CHANGED
@@ -68,6 +68,9 @@ export {
68
68
  type ValuePatternConfig,
69
69
  } from './attribute-redacting-processor';
70
70
 
71
+ // String redaction utility
72
+ export { createStringRedactor, type StringRedactor } from './redact-values';
73
+
71
74
  // Functional API (re-export for convenience)
72
75
  export type {
73
76
  TraceContext,
@@ -113,6 +116,7 @@ export { flush, shutdown } from './shutdown';
113
116
  // Request logger
114
117
  export {
115
118
  getRequestLogger,
119
+ runWithRequestContext,
116
120
  type RequestLogger,
117
121
  type RequestLogSnapshot,
118
122
  type RequestLoggerOptions,
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getStringRedactor } from './init';
3
+
4
+ describe('getStringRedactor', () => {
5
+ it('returns null when no attributeRedactor configured', () => {
6
+ expect(getStringRedactor()).toBeNull();
7
+ });
8
+ });
package/src/init.ts CHANGED
@@ -36,6 +36,7 @@ import { OTLPMetricExporter as OTLPMetricExporterHTTP } from '@opentelemetry/exp
36
36
  import { OTLPTraceExporter as OTLPTraceExporterHTTP } from '@opentelemetry/exporter-trace-otlp-http';
37
37
  import type { PushMetricExporter } from '@opentelemetry/sdk-metrics';
38
38
  import type { LogRecordProcessor } from '@opentelemetry/sdk-logs';
39
+ import { buildPostHogLogProcessors } from './posthog-logs';
39
40
  import { TailSamplingSpanProcessor } from './tail-sampling-processor';
40
41
  import { BaggageSpanProcessor } from './baggage-span-processor';
41
42
  import {
@@ -51,6 +52,7 @@ import {
51
52
  type AttributeRedactorConfig,
52
53
  type AttributeRedactorPreset,
53
54
  } from './attribute-redacting-processor';
55
+ import { createStringRedactor, type StringRedactor } from './redact-values';
54
56
  import { PrettyConsoleExporter } from './pretty-console-exporter';
55
57
  import { resolveConfigFromEnv } from './env-config';
56
58
  import { loadYamlConfig } from './yaml-config';
@@ -409,6 +411,21 @@ export interface AutotelConfig {
409
411
  */
410
412
  logRecordProcessors?: LogRecordProcessor[];
411
413
 
414
+ /**
415
+ * PostHog integration - auto-configures OTLP log exporter.
416
+ *
417
+ * @example
418
+ * ```typescript
419
+ * init({
420
+ * service: 'my-app',
421
+ * posthog: { url: 'https://us.i.posthog.com/i/v1/logs?token=phc_xxx' }
422
+ * });
423
+ * ```
424
+ *
425
+ * Also reads from POSTHOG_LOGS_URL environment variable as fallback.
426
+ */
427
+ posthog?: { url: string };
428
+
412
429
  /** Additional resource attributes to merge with defaults. */
413
430
  resourceAttributes?: Attributes;
414
431
 
@@ -1004,6 +1021,7 @@ let warnedOnce = false;
1004
1021
  let logger: Logger = silentLogger; // Silent by default - no spam
1005
1022
  let validationConfig: Partial<ValidationConfig> | null = null;
1006
1023
  let eventsConfig: EventsConfig | null = null;
1024
+ let _stringRedactor: StringRedactor | null = null;
1007
1025
 
1008
1026
  /**
1009
1027
  * Resolve metrics flag with env var override support
@@ -1287,6 +1305,23 @@ export function init(cfg: AutotelConfig): void {
1287
1305
  );
1288
1306
  }
1289
1307
 
1308
+ // Store string redactor for use by PostHog log/subscriber paths
1309
+ if (mergedConfig.attributeRedactor) {
1310
+ _stringRedactor = createStringRedactor(mergedConfig.attributeRedactor);
1311
+ }
1312
+
1313
+ // Wire string redactor to subscribers that support it (e.g., PostHogSubscriber)
1314
+ if (_stringRedactor && mergedConfig.subscribers) {
1315
+ for (const subscriber of mergedConfig.subscribers) {
1316
+ if (
1317
+ 'setStringRedactor' in subscriber &&
1318
+ typeof (subscriber as any).setStringRedactor === 'function'
1319
+ ) {
1320
+ (subscriber as any).setStringRedactor(_stringRedactor);
1321
+ }
1322
+ }
1323
+ }
1324
+
1290
1325
  // Step 2: Wrap with SpanNameNormalizingProcessor (middle)
1291
1326
  // Normalizer runs in onStart(), so span names are normalized before any onEnd processing
1292
1327
  if (mergedConfig.spanNameNormalizer && spanProcessors.length > 0) {
@@ -1337,6 +1372,19 @@ export function init(cfg: AutotelConfig): void {
1337
1372
  logRecordProcessors = [...mergedConfig.logRecordProcessors];
1338
1373
  }
1339
1374
 
1375
+ // PostHog OTLP logs integration
1376
+ const posthogProcessors = buildPostHogLogProcessors(
1377
+ mergedConfig.posthog,
1378
+ _stringRedactor,
1379
+ );
1380
+ if (posthogProcessors.length > 0) {
1381
+ if (!logRecordProcessors) {
1382
+ logRecordProcessors = [];
1383
+ }
1384
+ logRecordProcessors.push(...posthogProcessors);
1385
+ logger.info({}, '[autotel] PostHog OTLP logs configured');
1386
+ }
1387
+
1340
1388
  // Handle instrumentations: merge manual instrumentations with auto-instrumentations
1341
1389
  let finalInstrumentations: NodeSDKConfiguration['instrumentations'] =
1342
1390
  mergedConfig.instrumentations ? [...mergedConfig.instrumentations] : [];
@@ -1777,6 +1825,14 @@ function detectHostname(): string | undefined {
1777
1825
  }
1778
1826
  }
1779
1827
 
1828
+ /**
1829
+ * Get the string redactor configured via init({ attributeRedactor }).
1830
+ * Returns null if no redactor was configured.
1831
+ */
1832
+ export function getStringRedactor(): StringRedactor | null {
1833
+ return _stringRedactor;
1834
+ }
1835
+
1780
1836
  /**
1781
1837
  * Get SDK instance (for shutdown)
1782
1838
  */
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const packageJsonPath = resolve(here, '../package.json');
8
+
9
+ interface PackageJson {
10
+ bin?: Record<string, string>;
11
+ dependencies?: Record<string, string>;
12
+ }
13
+
14
+ describe('package manifest', () => {
15
+ it('declares runtime deps required by published bin scripts', () => {
16
+ const pkg = JSON.parse(
17
+ readFileSync(packageJsonPath, 'utf8'),
18
+ ) as PackageJson;
19
+
20
+ if (pkg.bin?.intent) {
21
+ expect(pkg.dependencies?.['@tanstack/intent']).toBeDefined();
22
+ }
23
+ });
24
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ const mockBatchLogRecordProcessor = vi.fn();
4
+ const mockOTLPLogExporter = vi.fn();
5
+
6
+ vi.mock('./node-require', () => ({
7
+ safeRequire: vi.fn((id: string) => {
8
+ if (id === '@opentelemetry/sdk-logs') {
9
+ return { BatchLogRecordProcessor: mockBatchLogRecordProcessor };
10
+ }
11
+ if (id === '@opentelemetry/exporter-logs-otlp-http') {
12
+ return { OTLPLogExporter: mockOTLPLogExporter };
13
+ }
14
+ return undefined;
15
+ }),
16
+ }));
17
+
18
+ import { buildPostHogLogProcessors } from './posthog-logs';
19
+
20
+ describe('buildPostHogLogProcessors', () => {
21
+ const originalEnv = process.env;
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ process.env = { ...originalEnv };
26
+ });
27
+
28
+ afterEach(() => {
29
+ process.env = originalEnv;
30
+ });
31
+
32
+ it('returns processor when posthog.url is configured', () => {
33
+ const result = buildPostHogLogProcessors({
34
+ url: 'https://us.i.posthog.com/i/v1/logs?token=phc_test',
35
+ });
36
+ expect(result).toHaveLength(1);
37
+ expect(mockOTLPLogExporter).toHaveBeenCalledWith({
38
+ url: 'https://us.i.posthog.com/i/v1/logs?token=phc_test',
39
+ });
40
+ expect(mockBatchLogRecordProcessor).toHaveBeenCalled();
41
+ });
42
+
43
+ it('returns processor from POSTHOG_LOGS_URL env var', () => {
44
+ process.env.POSTHOG_LOGS_URL =
45
+ 'https://eu.i.posthog.com/i/v1/logs?token=phc_eu';
46
+ const result = buildPostHogLogProcessors(undefined);
47
+ expect(result).toHaveLength(1);
48
+ expect(mockOTLPLogExporter).toHaveBeenCalledWith({
49
+ url: 'https://eu.i.posthog.com/i/v1/logs?token=phc_eu',
50
+ });
51
+ });
52
+
53
+ it('config.url takes precedence over env var', () => {
54
+ process.env.POSTHOG_LOGS_URL = 'https://env-url.com';
55
+ const result = buildPostHogLogProcessors({ url: 'https://config-url.com' });
56
+ expect(result).toHaveLength(1);
57
+ expect(mockOTLPLogExporter).toHaveBeenCalledWith({
58
+ url: 'https://config-url.com',
59
+ });
60
+ });
61
+
62
+ it('returns empty array when no url configured', () => {
63
+ const result = buildPostHogLogProcessors(undefined);
64
+ expect(result).toHaveLength(0);
65
+ expect(mockOTLPLogExporter).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('accepts a second stringRedactor parameter without error', () => {
69
+ const redactor = (s: string) => s.replace(/secret/g, '***');
70
+ const result = buildPostHogLogProcessors(
71
+ { url: 'https://us.i.posthog.com/i/v1/logs?token=phc_test' },
72
+ redactor,
73
+ );
74
+ expect(result).toHaveLength(1);
75
+ });
76
+
77
+ it('returns empty array when called with undefined, undefined', () => {
78
+ const result = buildPostHogLogProcessors(undefined, undefined);
79
+ expect(result).toHaveLength(0);
80
+ });
81
+
82
+ it('redacts string values inside attribute arrays before emitting', () => {
83
+ const wrappedOnEmit = vi.fn();
84
+ mockBatchLogRecordProcessor.mockImplementation(function () {
85
+ return {
86
+ onEmit: wrappedOnEmit,
87
+ shutdown: vi.fn().mockResolvedValue(undefined),
88
+ forceFlush: vi.fn().mockResolvedValue(undefined),
89
+ };
90
+ });
91
+
92
+ const [processor] = buildPostHogLogProcessors(
93
+ { url: 'https://us.i.posthog.com/i/v1/logs?token=phc_test' },
94
+ (value: string) => value.replace(/secret/g, '***'),
95
+ );
96
+
97
+ const record = {
98
+ body: 'ok',
99
+ attributes: {
100
+ tags: ['public', 'secret'],
101
+ },
102
+ };
103
+
104
+ processor.onEmit(record as any);
105
+
106
+ expect(wrappedOnEmit).toHaveBeenCalledWith(
107
+ expect.objectContaining({
108
+ attributes: {
109
+ tags: ['public', '***'],
110
+ },
111
+ }),
112
+ undefined,
113
+ );
114
+ });
115
+ });
@@ -0,0 +1,77 @@
1
+ import type { LogRecordProcessor } from '@opentelemetry/sdk-logs';
2
+ import { safeRequire } from './node-require';
3
+ import type { StringRedactor } from './redact-values';
4
+
5
+ class RedactingLogRecordProcessor implements LogRecordProcessor {
6
+ constructor(
7
+ private wrapped: LogRecordProcessor,
8
+ private redact: StringRedactor,
9
+ ) {}
10
+
11
+ onEmit(logRecord: any, context?: any): void {
12
+ if (logRecord.body && typeof logRecord.body === 'string') {
13
+ logRecord.body = this.redact(logRecord.body);
14
+ }
15
+ if (logRecord.attributes) {
16
+ for (const [key, value] of Object.entries(logRecord.attributes)) {
17
+ if (typeof value === 'string') {
18
+ logRecord.attributes[key] = this.redact(value);
19
+ } else if (Array.isArray(value)) {
20
+ logRecord.attributes[key] = value.map((item: unknown) =>
21
+ typeof item === 'string' ? this.redact(item) : item,
22
+ );
23
+ }
24
+ }
25
+ }
26
+ this.wrapped.onEmit(logRecord, context);
27
+ }
28
+
29
+ shutdown(): Promise<void> {
30
+ return this.wrapped.shutdown();
31
+ }
32
+
33
+ forceFlush(): Promise<void> {
34
+ return this.wrapped.forceFlush();
35
+ }
36
+ }
37
+
38
+ export interface PostHogConfig {
39
+ /** OTLP logs endpoint URL (e.g., https://us.i.posthog.com/i/v1/logs?token=phc_xxx) */
40
+ url: string;
41
+ }
42
+
43
+ /**
44
+ * Build log record processors for PostHog OTLP logs integration.
45
+ *
46
+ * Resolution order:
47
+ * 1. config.url if provided
48
+ * 2. POSTHOG_LOGS_URL env var
49
+ * 3. Empty array (disabled)
50
+ */
51
+ export function buildPostHogLogProcessors(
52
+ config: PostHogConfig | undefined,
53
+ stringRedactor?: StringRedactor | null,
54
+ ): LogRecordProcessor[] {
55
+ const url = config?.url || process.env.POSTHOG_LOGS_URL;
56
+ if (!url) return [];
57
+
58
+ const sdkLogs = safeRequire<{
59
+ BatchLogRecordProcessor: new (exporter: unknown) => LogRecordProcessor;
60
+ }>('@opentelemetry/sdk-logs');
61
+
62
+ const exporterModule = safeRequire<{
63
+ OTLPLogExporter: new (config: { url: string }) => unknown;
64
+ }>('@opentelemetry/exporter-logs-otlp-http');
65
+
66
+ if (!sdkLogs || !exporterModule) return [];
67
+
68
+ const exporter = new exporterModule.OTLPLogExporter({ url });
69
+ let processor: LogRecordProcessor = new sdkLogs.BatchLogRecordProcessor(
70
+ exporter,
71
+ );
72
+ if (stringRedactor) {
73
+ processor = new RedactingLogRecordProcessor(processor, stringRedactor);
74
+ }
75
+
76
+ return [processor];
77
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { createStringRedactor } from './redact-values';
3
+ import type { StringRedactor } from './redact-values';
4
+
5
+ describe('createStringRedactor', () => {
6
+ describe('default preset', () => {
7
+ let redact: StringRedactor;
8
+
9
+ beforeEach(() => {
10
+ redact = createStringRedactor('default');
11
+ });
12
+
13
+ it('redacts emails', () => {
14
+ expect(redact('Contact user@example.com for info')).toBe(
15
+ 'Contact [REDACTED] for info',
16
+ );
17
+ });
18
+
19
+ it('redacts phone numbers', () => {
20
+ expect(redact('Call 555-123-4567 now')).toBe('Call [REDACTED] now');
21
+ });
22
+
23
+ it('redacts credit card numbers', () => {
24
+ expect(redact('Card: 4111-1111-1111-1111')).toBe('Card: [REDACTED]');
25
+ });
26
+
27
+ it('returns input unchanged when no patterns match', () => {
28
+ expect(redact('hello world')).toBe('hello world');
29
+ });
30
+ });
31
+
32
+ describe('strict preset', () => {
33
+ let redact: StringRedactor;
34
+
35
+ beforeEach(() => {
36
+ redact = createStringRedactor('strict');
37
+ });
38
+
39
+ it('redacts JWTs', () => {
40
+ const jwt =
41
+ 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_-def';
42
+ expect(redact(`Token: ${jwt}`)).toBe('Token: [REDACTED]');
43
+ });
44
+
45
+ it('redacts bearer tokens', () => {
46
+ expect(redact('Authorization: Bearer abc123.xyz')).toBe(
47
+ 'Authorization: [REDACTED]',
48
+ );
49
+ });
50
+ });
51
+
52
+ describe('custom config', () => {
53
+ it('accepts custom config with custom patterns', () => {
54
+ const redact = createStringRedactor({
55
+ valuePatterns: [{ name: 'customId', pattern: /CUST-\d{8}/g }],
56
+ replacement: '***',
57
+ });
58
+ expect(redact('Customer CUST-12345678 logged in')).toBe(
59
+ 'Customer *** logged in',
60
+ );
61
+ });
62
+
63
+ it('uses custom replacement string', () => {
64
+ const redact = createStringRedactor({
65
+ valuePatterns: [
66
+ {
67
+ name: 'email',
68
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi,
69
+ },
70
+ ],
71
+ replacement: '<HIDDEN>',
72
+ });
73
+ expect(redact('Email: test@example.com')).toBe('Email: <HIDDEN>');
74
+ });
75
+ });
76
+ });
@@ -0,0 +1,27 @@
1
+ /** Standalone string redaction for use outside the span processor pipeline. */
2
+
3
+ import {
4
+ REDACTOR_PRESETS,
5
+ type AttributeRedactorConfig,
6
+ type AttributeRedactorPreset,
7
+ type ValuePatternConfig,
8
+ } from './attribute-redacting-processor';
9
+
10
+ export type StringRedactor = (value: string) => string;
11
+ export function createStringRedactor(
12
+ config: AttributeRedactorConfig | AttributeRedactorPreset,
13
+ ): StringRedactor {
14
+ const resolved =
15
+ typeof config === 'string' ? REDACTOR_PRESETS[config] : config;
16
+ const valuePatterns: ValuePatternConfig[] = resolved.valuePatterns ?? [];
17
+ const defaultReplacement = resolved.replacement ?? '[REDACTED]';
18
+
19
+ return (value: string): string => {
20
+ let result = value;
21
+ for (const { pattern, replacement } of valuePatterns) {
22
+ pattern.lastIndex = 0;
23
+ result = result.replaceAll(pattern, replacement ?? defaultReplacement);
24
+ }
25
+ return result;
26
+ };
27
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { getRequestLogger } from './request-logger';
2
+ import { getRequestLogger, runWithRequestContext } from './request-logger';
3
3
  import type { TraceContext } from './trace-context';
4
4
 
5
5
  function createMockContext(): TraceContext {
@@ -121,4 +121,17 @@ describe('getRequestLogger', () => {
121
121
  await new Promise((resolve) => setImmediate(resolve));
122
122
  expect(onEmit).toHaveBeenCalledWith(snapshot);
123
123
  });
124
+
125
+ it('resolves context from AsyncLocalStorage when no args given', () => {
126
+ const ctx = createMockContext();
127
+
128
+ runWithRequestContext(ctx, () => {
129
+ const log = getRequestLogger();
130
+ log.set({ user: { id: 'als-user' } });
131
+
132
+ expect(ctx.setAttributes).toHaveBeenCalledWith({
133
+ 'user.id': 'als-user',
134
+ });
135
+ });
136
+ });
124
137
  });
@@ -1,9 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { trace as otelTrace } from '@opentelemetry/api';
2
3
  import type { TraceContext } from './trace-context';
3
4
  import { createTraceContext } from './trace-context';
4
5
  import { recordStructuredError } from './structured-error';
5
6
  import { flattenToAttributes } from './flatten-attributes';
6
7
 
8
+ const requestContextStore = new AsyncLocalStorage<TraceContext>();
9
+
10
+ export function runWithRequestContext<T>(ctx: TraceContext, fn: () => T): T {
11
+ return requestContextStore.run(ctx, fn);
12
+ }
13
+
7
14
  export interface RequestLogger {
8
15
  set(fields: Record<string, unknown>): void;
9
16
  info(message: string, fields?: Record<string, unknown>): void;
@@ -29,10 +36,13 @@ export interface RequestLoggerOptions {
29
36
  function resolveContext(ctx?: TraceContext): TraceContext {
30
37
  if (ctx) return ctx;
31
38
 
39
+ const stored = requestContextStore.getStore();
40
+ if (stored) return stored;
41
+
32
42
  const span = otelTrace.getActiveSpan();
33
43
  if (!span) {
34
44
  throw new Error(
35
- '[autotel] getRequestLogger() requires an active span. Wrap your handler with trace().',
45
+ '[autotel] getRequestLogger() requires an active span or runWithRequestContext(). Wrap your handler with trace() or use runWithRequestContext().',
36
46
  );
37
47
  }
38
48
  return createTraceContext(span);
@@ -27,6 +27,36 @@ describe('structured-error helpers', () => {
27
27
  expect(err.details).toEqual({ retryable: false, provider: 'stripe' });
28
28
  });
29
29
 
30
+ it('toString() renders a human-readable diagnostic summary', () => {
31
+ const err = createStructuredError({
32
+ message: 'Payment failed',
33
+ why: 'Card declined by issuer',
34
+ fix: 'Use a different card',
35
+ link: 'https://docs.example.com/errors/card-declined',
36
+ code: 'PAYMENT_DECLINED',
37
+ status: 402,
38
+ cause: new Error('upstream timeout'),
39
+ });
40
+
41
+ const output = err.toString();
42
+ expect(output).toBe(
43
+ [
44
+ 'StructuredError: Payment failed',
45
+ ' Why: Card declined by issuer',
46
+ ' Fix: Use a different card',
47
+ ' Link: https://docs.example.com/errors/card-declined',
48
+ ' Code: PAYMENT_DECLINED',
49
+ ' Status: 402',
50
+ ' Caused by: Error: upstream timeout',
51
+ ].join('\n'),
52
+ );
53
+ });
54
+
55
+ it('toString() omits undefined fields', () => {
56
+ const err = createStructuredError({ message: 'Something broke' });
57
+ expect(err.toString()).toBe('StructuredError: Something broke');
58
+ });
59
+
30
60
  it('extracts canonical attributes for span logging', () => {
31
61
  const err = createStructuredError({
32
62
  message: 'Export failed',