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.
- package/bin/intent.js +6 -0
- package/dist/auto.cjs +2 -2
- package/dist/auto.js +1 -1
- package/dist/{chunk-GY4CRZSV.js → chunk-2D74YR4Z.js} +3 -3
- package/dist/{chunk-GY4CRZSV.js.map → chunk-2D74YR4Z.js.map} +1 -1
- package/dist/{chunk-724XLWR3.js → chunk-47KW5CV7.js} +87 -4
- package/dist/chunk-47KW5CV7.js.map +1 -0
- package/dist/{chunk-3ZFDJJWZ.cjs → chunk-AXNPD6XW.cjs} +26 -26
- package/dist/{chunk-3ZFDJJWZ.cjs.map → chunk-AXNPD6XW.cjs.map} +1 -1
- package/dist/{chunk-BSZP4URK.cjs → chunk-CLR5RQW4.cjs} +5 -5
- package/dist/{chunk-BSZP4URK.cjs.map → chunk-CLR5RQW4.cjs.map} +1 -1
- package/dist/{chunk-AXFWWJF3.cjs → chunk-CSG2D3BZ.cjs} +7 -7
- package/dist/{chunk-AXFWWJF3.cjs.map → chunk-CSG2D3BZ.cjs.map} +1 -1
- package/dist/{chunk-MSUHW2I4.cjs → chunk-CZR7PJUZ.cjs} +13 -13
- package/dist/{chunk-MSUHW2I4.cjs.map → chunk-CZR7PJUZ.cjs.map} +1 -1
- package/dist/{chunk-IPKXURBW.js → chunk-GSZJOYFF.js} +3 -3
- package/dist/{chunk-IPKXURBW.js.map → chunk-GSZJOYFF.js.map} +1 -1
- package/dist/{chunk-WCIIFRGL.js → chunk-IJMW6CTV.js} +3 -3
- package/dist/{chunk-WCIIFRGL.js.map → chunk-IJMW6CTV.js.map} +1 -1
- package/dist/{chunk-HSEIUH7F.cjs → chunk-PDRIJURL.cjs} +86 -2
- package/dist/chunk-PDRIJURL.cjs.map +1 -0
- package/dist/{chunk-4RZ4JUBY.cjs → chunk-W6U2BUHU.cjs} +5 -5
- package/dist/{chunk-4RZ4JUBY.cjs.map → chunk-W6U2BUHU.cjs.map} +1 -1
- package/dist/{chunk-5XUEHX7J.js → chunk-Z3BQYTZE.js} +3 -3
- package/dist/{chunk-5XUEHX7J.js.map → chunk-Z3BQYTZE.js.map} +1 -1
- package/dist/{chunk-3AMR5XLZ.js → chunk-ZJTBAUXG.js} +3 -3
- package/dist/{chunk-3AMR5XLZ.js.map → chunk-ZJTBAUXG.js.map} +1 -1
- package/dist/decorators.cjs +2 -2
- package/dist/decorators.js +2 -2
- package/dist/enrichers.cjs +90 -0
- package/dist/enrichers.cjs.map +1 -0
- package/dist/enrichers.d.cts +23 -0
- package/dist/enrichers.d.ts +23 -0
- package/dist/enrichers.js +86 -0
- package/dist/enrichers.js.map +1 -0
- package/dist/event.cjs +5 -5
- package/dist/event.js +2 -2
- package/dist/functional.cjs +9 -9
- package/dist/functional.js +2 -2
- package/dist/index.cjs +63 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +28 -11
- package/dist/index.js.map +1 -1
- package/dist/{init-BC5aN8bh.d.cts → init-Co9CWOY6.d.cts} +16 -0
- package/dist/{init-_FG4IbhF.d.ts → init-oLqNJTpj.d.ts} +16 -0
- package/dist/instrumentation.cjs +8 -8
- package/dist/instrumentation.js +1 -1
- package/dist/messaging.cjs +6 -6
- package/dist/messaging.js +3 -3
- package/dist/semantic-helpers.cjs +7 -7
- package/dist/semantic-helpers.js +3 -3
- package/dist/webhook.cjs +3 -3
- package/dist/webhook.js +2 -2
- package/dist/workflow-distributed.cjs +4 -4
- package/dist/workflow-distributed.js +2 -2
- package/dist/workflow.cjs +7 -7
- package/dist/workflow.js +3 -3
- package/dist/yaml-config.d.cts +1 -1
- package/dist/yaml-config.d.ts +1 -1
- package/package.json +45 -33
- package/skills/autotel-core/SKILL.md +162 -0
- package/skills/autotel-events/SKILL.md +88 -0
- package/skills/autotel-frameworks/SKILL.md +97 -0
- package/skills/autotel-instrumentation/SKILL.md +125 -0
- package/skills/autotel-request-logging/SKILL.md +100 -0
- package/skills/autotel-structured-errors/SKILL.md +130 -0
- package/src/enrichers.test.ts +150 -0
- package/src/enrichers.ts +145 -0
- package/src/index.ts +4 -0
- package/src/init-redactor.test.ts +8 -0
- package/src/init.ts +56 -0
- package/src/package-manifest.test.ts +24 -0
- package/src/posthog-logs.test.ts +115 -0
- package/src/posthog-logs.ts +77 -0
- package/src/redact-values.test.ts +76 -0
- package/src/redact-values.ts +27 -0
- package/src/request-logger.test.ts +14 -1
- package/src/request-logger.ts +11 -1
- package/src/structured-error.test.ts +30 -0
- package/src/structured-error.ts +11 -0
- package/dist/chunk-724XLWR3.js.map +0 -1
- package/dist/chunk-HSEIUH7F.cjs.map +0 -1
package/src/enrichers.ts
ADDED
|
@@ -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,
|
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
|
});
|
package/src/request-logger.ts
CHANGED
|
@@ -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',
|