deepline 0.1.222 → 0.1.224
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/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +396 -176
- package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +6 -3
- package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +210 -105
- package/dist/bundling-sources/sdk/src/release.ts +2 -2
- package/dist/bundling-sources/shared_libs/observability/node-tracing.ts +125 -0
- package/dist/bundling-sources/shared_libs/observability/redaction.ts +94 -0
- package/dist/bundling-sources/shared_libs/observability/telemetry.ts +414 -0
- package/dist/bundling-sources/shared_libs/observability/tracing.ts +93 -0
- package/dist/bundling-sources/shared_libs/observability/worker-telemetry.ts +119 -0
- package/dist/bundling-sources/shared_libs/play-runtime/secret-redaction.ts +32 -14
- package/dist/cli/index.js +220 -164
- package/dist/cli/index.mjs +220 -164
- package/dist/index.js +72 -56
- package/dist/index.mjs +72 -56
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdir, appendFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
+
import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
|
|
5
|
+
import type { ExportResult } from '@opentelemetry/core';
|
|
6
|
+
|
|
7
|
+
const EXPORT_RESULT_SUCCESS = 0;
|
|
8
|
+
const EXPORT_RESULT_FAILED = 1;
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
var __deeplineTracingInitPromise: Promise<boolean> | undefined;
|
|
12
|
+
var __deeplineTracingSdk: NodeSDK | undefined;
|
|
13
|
+
var __deeplineTracingShutdownPromise: Promise<void> | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class JsonlFileSpanExporter implements SpanExporter {
|
|
17
|
+
constructor(private readonly filePath: string) {}
|
|
18
|
+
|
|
19
|
+
async export(
|
|
20
|
+
spans: ReadableSpan[],
|
|
21
|
+
resultCallback: (result: ExportResult) => void,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
25
|
+
const lines = spans
|
|
26
|
+
.map((span) =>
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
traceId: span.spanContext().traceId,
|
|
29
|
+
spanId: span.spanContext().spanId,
|
|
30
|
+
parentSpanId: span.parentSpanContext?.spanId ?? null,
|
|
31
|
+
name: span.name,
|
|
32
|
+
kind: span.kind,
|
|
33
|
+
startTime: span.startTime,
|
|
34
|
+
endTime: span.endTime,
|
|
35
|
+
attributes: span.attributes,
|
|
36
|
+
status: span.status,
|
|
37
|
+
resource: span.resource.attributes,
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
.join('\n');
|
|
41
|
+
if (lines.length > 0) {
|
|
42
|
+
await appendFile(this.filePath, `${lines}\n`, 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
resultCallback({ code: EXPORT_RESULT_SUCCESS });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
resultCallback({
|
|
47
|
+
code: EXPORT_RESULT_FAILED,
|
|
48
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async shutdown(): Promise<void> {}
|
|
54
|
+
async forceFlush(): Promise<void> {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function resolveTraceExporters(): Promise<SpanExporter[]> {
|
|
58
|
+
const exporters: SpanExporter[] = [];
|
|
59
|
+
const otlpEndpoint =
|
|
60
|
+
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim() ||
|
|
61
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() ||
|
|
62
|
+
'';
|
|
63
|
+
if (otlpEndpoint) {
|
|
64
|
+
const { OTLPTraceExporter } =
|
|
65
|
+
await import('@opentelemetry/exporter-trace-otlp-http');
|
|
66
|
+
exporters.push(new OTLPTraceExporter());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const traceFilePath = process.env.DEEPLINE_TRACE_FILE?.trim() || '';
|
|
70
|
+
if (traceFilePath) {
|
|
71
|
+
exporters.push(new JsonlFileSpanExporter(traceFilePath));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return exporters;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function startNodeTracing(serviceName: string): Promise<boolean> {
|
|
78
|
+
const exporters = await resolveTraceExporters();
|
|
79
|
+
if (exporters.length === 0) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!process.env.OTEL_SERVICE_NAME) {
|
|
84
|
+
process.env.OTEL_SERVICE_NAME = serviceName;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [{ NodeSDK }, { BatchSpanProcessor }] = await Promise.all([
|
|
88
|
+
import('@opentelemetry/sdk-node'),
|
|
89
|
+
import('@opentelemetry/sdk-trace-base'),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const sdk = new NodeSDK({
|
|
93
|
+
instrumentations: [],
|
|
94
|
+
spanProcessors: exporters.map(
|
|
95
|
+
(exporter) => new BatchSpanProcessor(exporter),
|
|
96
|
+
),
|
|
97
|
+
});
|
|
98
|
+
await sdk.start();
|
|
99
|
+
globalThis.__deeplineTracingSdk = sdk;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function ensureNodeTracing(serviceName: string): Promise<boolean> {
|
|
104
|
+
globalThis.__deeplineTracingInitPromise ??= startNodeTracing(serviceName);
|
|
105
|
+
return await globalThis.__deeplineTracingInitPromise;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function shutdownNodeTracing(): Promise<void> {
|
|
109
|
+
const initialized = await (globalThis.__deeplineTracingInitPromise ??
|
|
110
|
+
Promise.resolve(false));
|
|
111
|
+
if (!initialized) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sdk = globalThis.__deeplineTracingSdk;
|
|
116
|
+
if (!sdk) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
globalThis.__deeplineTracingShutdownPromise ??= sdk.shutdown().finally(() => {
|
|
121
|
+
globalThis.__deeplineTracingSdk = undefined;
|
|
122
|
+
globalThis.__deeplineTracingInitPromise = undefined;
|
|
123
|
+
});
|
|
124
|
+
await globalThis.__deeplineTracingShutdownPromise;
|
|
125
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const SENSITIVE_FIELD_NAME =
|
|
2
|
+
'authorization|cookie|password|secret|client[_-]?secret|access[_-]?token|refresh[_-]?token|(?:x[_-]?)?api[_-]?key|private[_-]?key|token|credentials';
|
|
3
|
+
const INLINE_SECRET_FIELD_NAME =
|
|
4
|
+
'cookie|password|secret|client[_-]?secret|access[_-]?token|refresh[_-]?token|(?:x[_-]?)?api[_-]?key|token|credentials';
|
|
5
|
+
const SENSITIVE_FIELD_PATTERN = new RegExp(
|
|
6
|
+
`(^|[._-])(${SENSITIVE_FIELD_NAME})$`,
|
|
7
|
+
'i',
|
|
8
|
+
);
|
|
9
|
+
const BEARER_CREDENTIAL_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
10
|
+
const BASIC_CREDENTIAL_PATTERN =
|
|
11
|
+
/\bBasic\s+[A-Za-z0-9+/]{4,}={0,2}(?=\s|$|[,;])/gi;
|
|
12
|
+
const PRIVATE_KEY_PATTERN =
|
|
13
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi;
|
|
14
|
+
const AUTHORIZATION_CREDENTIAL_PATTERN =
|
|
15
|
+
/\b(authorization)(\s*[:=]\s*)(Bearer|Basic)\s+\S+/gi;
|
|
16
|
+
const JWT_PATTERN =
|
|
17
|
+
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{1,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
18
|
+
const JSON_SECRET_PATTERN = new RegExp(
|
|
19
|
+
`(["'])(${SENSITIVE_FIELD_NAME})\\1(\\s*:\\s*)(["'])(.*?)\\4`,
|
|
20
|
+
'gi',
|
|
21
|
+
);
|
|
22
|
+
const INLINE_SECRET_PATTERN = new RegExp(
|
|
23
|
+
`\\b(${INLINE_SECRET_FIELD_NAME})(\\s*[:=]\\s*)([^\\s,;]+)`,
|
|
24
|
+
'gi',
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** Whether a structured telemetry field name must never retain its value. */
|
|
28
|
+
export function isSensitiveTelemetryFieldName(value: string): boolean {
|
|
29
|
+
return SENSITIVE_FIELD_PATTERN.test(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function redactStructuredValue(
|
|
33
|
+
key: string,
|
|
34
|
+
value: unknown,
|
|
35
|
+
seen = new WeakSet<object>(),
|
|
36
|
+
): unknown {
|
|
37
|
+
if (isSensitiveTelemetryFieldName(key)) return '[REDACTED]';
|
|
38
|
+
if (typeof value === 'string') return redactPlainTelemetryText(value);
|
|
39
|
+
if (!value || typeof value !== 'object') return value;
|
|
40
|
+
if (seen.has(value)) return '[circular]';
|
|
41
|
+
seen.add(value);
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
const redacted = value.map((item) =>
|
|
44
|
+
redactStructuredValue('item', item, seen),
|
|
45
|
+
);
|
|
46
|
+
seen.delete(value);
|
|
47
|
+
return redacted;
|
|
48
|
+
}
|
|
49
|
+
const redacted = Object.fromEntries(
|
|
50
|
+
Object.entries(value).map(([childKey, childValue]) => [
|
|
51
|
+
childKey,
|
|
52
|
+
redactStructuredValue(childKey, childValue, seen),
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
seen.delete(value);
|
|
56
|
+
return redacted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function redactPlainTelemetryText(value: string): string {
|
|
60
|
+
return value
|
|
61
|
+
.replace(PRIVATE_KEY_PATTERN, '[REDACTED]')
|
|
62
|
+
.replace(AUTHORIZATION_CREDENTIAL_PATTERN, '$1$2$3 [REDACTED]')
|
|
63
|
+
.replace(BEARER_CREDENTIAL_PATTERN, 'Bearer [REDACTED]')
|
|
64
|
+
.replace(BASIC_CREDENTIAL_PATTERN, 'Basic [REDACTED]')
|
|
65
|
+
.replace(JWT_PATTERN, '[REDACTED_JWT]')
|
|
66
|
+
.replace(
|
|
67
|
+
JSON_SECRET_PATTERN,
|
|
68
|
+
(_match, keyQuote, key, separator, valueQuote) =>
|
|
69
|
+
`${keyQuote}${key}${keyQuote}${separator}${valueQuote}[REDACTED]${valueQuote}`,
|
|
70
|
+
)
|
|
71
|
+
.replace(INLINE_SECRET_PATTERN, '$1$2[REDACTED]');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Redact credentials from arbitrary diagnostic text. Kept dependency-free so
|
|
76
|
+
* the play-worker bundle can share exactly the same base redaction rules as
|
|
77
|
+
* server telemetry without importing the full telemetry emitter.
|
|
78
|
+
*/
|
|
79
|
+
export function redactTelemetryText(value: string): string {
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
if (
|
|
82
|
+
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
83
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
84
|
+
) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.stringify(
|
|
87
|
+
redactStructuredValue('serializedJson', JSON.parse(trimmed)),
|
|
88
|
+
);
|
|
89
|
+
} catch {
|
|
90
|
+
// Non-JSON diagnostic strings still use pattern-based redaction below.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return redactPlainTelemetryText(value);
|
|
94
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSensitiveTelemetryFieldName,
|
|
3
|
+
redactTelemetryText,
|
|
4
|
+
} from './redaction';
|
|
5
|
+
|
|
6
|
+
export { isSensitiveTelemetryFieldName } from './redaction';
|
|
7
|
+
|
|
8
|
+
export const TELEMETRY_SCHEMA_VERSION = 1 as const;
|
|
9
|
+
|
|
10
|
+
export type TelemetryScalar = string | number | boolean | null | undefined;
|
|
11
|
+
export type TelemetryValue =
|
|
12
|
+
| Exclude<TelemetryScalar, undefined>
|
|
13
|
+
| readonly TelemetryValue[]
|
|
14
|
+
| { readonly [key: string]: TelemetryValue };
|
|
15
|
+
export type TelemetryFields = Readonly<Record<string, unknown>>;
|
|
16
|
+
|
|
17
|
+
export type TelemetryContext = {
|
|
18
|
+
environment?: string | null;
|
|
19
|
+
release?: string | null;
|
|
20
|
+
orgId?: string | null;
|
|
21
|
+
orgName?: string | null;
|
|
22
|
+
userId?: string | null;
|
|
23
|
+
apiKeyId?: string | null;
|
|
24
|
+
authType?: string | null;
|
|
25
|
+
requestId?: string | null;
|
|
26
|
+
runId?: string | null;
|
|
27
|
+
workflowId?: string | null;
|
|
28
|
+
workflowName?: string | null;
|
|
29
|
+
workflowOrigin?: string | null;
|
|
30
|
+
playName?: string | null;
|
|
31
|
+
machineId?: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type TelemetryLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
35
|
+
export type TelemetryMetricKind = 'counter' | 'gauge' | 'distribution';
|
|
36
|
+
export type TelemetrySpanOutcome = 'ok' | 'error';
|
|
37
|
+
|
|
38
|
+
type TelemetryEventBase = {
|
|
39
|
+
telemetrySchemaVersion: typeof TELEMETRY_SCHEMA_VERSION;
|
|
40
|
+
timestamp: string;
|
|
41
|
+
service: string;
|
|
42
|
+
component: string | null;
|
|
43
|
+
event: string;
|
|
44
|
+
tag: string;
|
|
45
|
+
} & Omit<TelemetryContext, never> &
|
|
46
|
+
Record<string, TelemetryValue | undefined>;
|
|
47
|
+
|
|
48
|
+
export type TelemetryLogEvent = TelemetryEventBase & {
|
|
49
|
+
telemetryKind: 'log';
|
|
50
|
+
telemetryLevel: TelemetryLevel;
|
|
51
|
+
errorType?: string;
|
|
52
|
+
errorMessage?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type TelemetryMetricEvent = TelemetryEventBase & {
|
|
56
|
+
telemetryKind: 'metric';
|
|
57
|
+
telemetryMetricKind: TelemetryMetricKind;
|
|
58
|
+
telemetryMetricValue: number;
|
|
59
|
+
telemetryMetricUnit: string | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type TelemetrySpanEvent = TelemetryEventBase & {
|
|
63
|
+
telemetryKind: 'span';
|
|
64
|
+
telemetryLevel: 'info' | 'error';
|
|
65
|
+
spanDurationMs: number;
|
|
66
|
+
spanOutcome: TelemetrySpanOutcome;
|
|
67
|
+
errorType?: string;
|
|
68
|
+
errorMessage?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type TelemetryEvent =
|
|
72
|
+
| TelemetryLogEvent
|
|
73
|
+
| TelemetryMetricEvent
|
|
74
|
+
| TelemetrySpanEvent;
|
|
75
|
+
|
|
76
|
+
export type TelemetryAdapter = {
|
|
77
|
+
emit(event: TelemetryEvent): void | Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type TelemetryEventOptions = { tag?: string };
|
|
81
|
+
|
|
82
|
+
export type Telemetry = {
|
|
83
|
+
child(input: { component?: string; context?: TelemetryContext }): Telemetry;
|
|
84
|
+
debug(
|
|
85
|
+
event: string,
|
|
86
|
+
fields?: TelemetryFields,
|
|
87
|
+
options?: TelemetryEventOptions,
|
|
88
|
+
): Promise<void>;
|
|
89
|
+
info(
|
|
90
|
+
event: string,
|
|
91
|
+
fields?: TelemetryFields,
|
|
92
|
+
options?: TelemetryEventOptions,
|
|
93
|
+
): Promise<void>;
|
|
94
|
+
warn(
|
|
95
|
+
event: string,
|
|
96
|
+
fields?: TelemetryFields,
|
|
97
|
+
options?: TelemetryEventOptions,
|
|
98
|
+
): Promise<void>;
|
|
99
|
+
error(
|
|
100
|
+
event: string,
|
|
101
|
+
error?: unknown,
|
|
102
|
+
fields?: TelemetryFields,
|
|
103
|
+
options?: TelemetryEventOptions,
|
|
104
|
+
): Promise<void>;
|
|
105
|
+
counter(
|
|
106
|
+
metric: string,
|
|
107
|
+
value?: number,
|
|
108
|
+
fields?: TelemetryFields,
|
|
109
|
+
): Promise<void>;
|
|
110
|
+
gauge(metric: string, value: number, fields?: TelemetryFields): Promise<void>;
|
|
111
|
+
distribution(
|
|
112
|
+
metric: string,
|
|
113
|
+
value: number,
|
|
114
|
+
options?: { unit?: string; fields?: TelemetryFields },
|
|
115
|
+
): Promise<void>;
|
|
116
|
+
time<T>(
|
|
117
|
+
span: string,
|
|
118
|
+
fields: TelemetryFields | undefined,
|
|
119
|
+
fn: () => Promise<T> | T,
|
|
120
|
+
): Promise<T>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type CreateTelemetryInput = {
|
|
124
|
+
service: string;
|
|
125
|
+
component?: string;
|
|
126
|
+
context?: TelemetryContext;
|
|
127
|
+
adapter?: TelemetryAdapter;
|
|
128
|
+
now?: () => Date;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const MAX_FIELD_STRING_LENGTH = 8_000;
|
|
132
|
+
const MAX_FIELD_DEPTH = 8;
|
|
133
|
+
const MAX_ARRAY_ITEMS = 100;
|
|
134
|
+
const MAX_OBJECT_KEYS = 100;
|
|
135
|
+
|
|
136
|
+
const RESERVED_FIELDS = new Set([
|
|
137
|
+
'telemetrySchemaVersion',
|
|
138
|
+
'timestamp',
|
|
139
|
+
'service',
|
|
140
|
+
'component',
|
|
141
|
+
'event',
|
|
142
|
+
'tag',
|
|
143
|
+
'telemetryKind',
|
|
144
|
+
'telemetryLevel',
|
|
145
|
+
'telemetryMetricKind',
|
|
146
|
+
'telemetryMetricValue',
|
|
147
|
+
'telemetryMetricUnit',
|
|
148
|
+
'spanDurationMs',
|
|
149
|
+
'spanOutcome',
|
|
150
|
+
'environment',
|
|
151
|
+
'release',
|
|
152
|
+
'orgId',
|
|
153
|
+
'orgName',
|
|
154
|
+
'userId',
|
|
155
|
+
'apiKeyId',
|
|
156
|
+
'authType',
|
|
157
|
+
'requestId',
|
|
158
|
+
'runId',
|
|
159
|
+
'workflowId',
|
|
160
|
+
'workflowName',
|
|
161
|
+
'workflowOrigin',
|
|
162
|
+
'playName',
|
|
163
|
+
'machineId',
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
function normalizeName(value: string, label: string): string {
|
|
167
|
+
const normalized = value.trim();
|
|
168
|
+
if (!normalized) throw new Error(`${label} must not be empty.`);
|
|
169
|
+
return normalized;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function redactTelemetryString(value: string): string {
|
|
173
|
+
return redactTelemetryText(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeFieldValue(
|
|
177
|
+
key: string,
|
|
178
|
+
value: unknown,
|
|
179
|
+
depth = 0,
|
|
180
|
+
seen = new WeakSet<object>(),
|
|
181
|
+
): TelemetryValue | undefined {
|
|
182
|
+
if (isSensitiveTelemetryFieldName(key)) return '[REDACTED]';
|
|
183
|
+
if (value === null) return null;
|
|
184
|
+
if (typeof value === 'boolean') return value;
|
|
185
|
+
if (typeof value === 'number') {
|
|
186
|
+
return Number.isFinite(value) ? value : undefined;
|
|
187
|
+
}
|
|
188
|
+
if (typeof value === 'string') {
|
|
189
|
+
const redacted = redactTelemetryString(value);
|
|
190
|
+
if (redacted.length <= MAX_FIELD_STRING_LENGTH) return redacted;
|
|
191
|
+
return `${redacted.slice(0, MAX_FIELD_STRING_LENGTH)}…`;
|
|
192
|
+
}
|
|
193
|
+
if (typeof value === 'bigint') return String(value);
|
|
194
|
+
if (value instanceof Date) return value.toISOString();
|
|
195
|
+
if (value instanceof Error) {
|
|
196
|
+
return {
|
|
197
|
+
name: value.name || 'Error',
|
|
198
|
+
message: normalizeFieldValue(
|
|
199
|
+
'message',
|
|
200
|
+
value.message,
|
|
201
|
+
depth + 1,
|
|
202
|
+
seen,
|
|
203
|
+
) as string,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (typeof value !== 'object' || value === undefined) return undefined;
|
|
207
|
+
if (depth >= MAX_FIELD_DEPTH) return '[max-depth]';
|
|
208
|
+
if (seen.has(value)) return '[circular]';
|
|
209
|
+
seen.add(value);
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
const normalized = value
|
|
212
|
+
.slice(0, MAX_ARRAY_ITEMS)
|
|
213
|
+
.map(
|
|
214
|
+
(item) => normalizeFieldValue('item', item, depth + 1, seen) ?? null,
|
|
215
|
+
);
|
|
216
|
+
if (value.length > MAX_ARRAY_ITEMS) normalized.push('[truncated]');
|
|
217
|
+
seen.delete(value);
|
|
218
|
+
return normalized;
|
|
219
|
+
}
|
|
220
|
+
const normalized: Record<string, TelemetryValue> = {};
|
|
221
|
+
const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS);
|
|
222
|
+
for (const [childKey, childValue] of entries) {
|
|
223
|
+
const item = normalizeFieldValue(childKey, childValue, depth + 1, seen);
|
|
224
|
+
if (item !== undefined) normalized[childKey] = item;
|
|
225
|
+
}
|
|
226
|
+
if (Object.keys(value).length > MAX_OBJECT_KEYS) {
|
|
227
|
+
normalized.__truncated__ = true;
|
|
228
|
+
}
|
|
229
|
+
seen.delete(value);
|
|
230
|
+
return normalized;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeFields(fields: TelemetryFields | undefined) {
|
|
234
|
+
const normalized: Record<string, TelemetryValue> = {};
|
|
235
|
+
for (const [key, value] of Object.entries(fields ?? {})) {
|
|
236
|
+
if (RESERVED_FIELDS.has(key) || value === undefined) continue;
|
|
237
|
+
const normalizedValue = normalizeFieldValue(key, value);
|
|
238
|
+
if (normalizedValue !== undefined) normalized[key] = normalizedValue;
|
|
239
|
+
}
|
|
240
|
+
return normalized;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeContext(context: TelemetryContext | undefined) {
|
|
244
|
+
return Object.fromEntries(
|
|
245
|
+
Object.entries(context ?? {}).filter(([, value]) => value !== undefined),
|
|
246
|
+
) as TelemetryContext;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function errorFields(
|
|
250
|
+
error: unknown,
|
|
251
|
+
): Partial<Pick<TelemetrySpanEvent, 'errorType' | 'errorMessage'>> {
|
|
252
|
+
if (error === undefined || error === null) return {};
|
|
253
|
+
if (error instanceof Error) {
|
|
254
|
+
return {
|
|
255
|
+
errorType: error.name || 'Error',
|
|
256
|
+
errorMessage: redactTelemetryString(error.message),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
errorType: 'Error',
|
|
261
|
+
errorMessage: redactTelemetryString(String(error)),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function createConsoleTelemetryAdapter(input?: {
|
|
266
|
+
console?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
|
267
|
+
}): TelemetryAdapter {
|
|
268
|
+
const target = input?.console ?? console;
|
|
269
|
+
return {
|
|
270
|
+
emit(event) {
|
|
271
|
+
const line = JSON.stringify(event);
|
|
272
|
+
if (event.telemetryKind === 'log') target[event.telemetryLevel](line);
|
|
273
|
+
else if (event.telemetryKind === 'span' && event.spanOutcome === 'error')
|
|
274
|
+
target.error(line);
|
|
275
|
+
else target.info(line);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function composeTelemetryAdapters(
|
|
281
|
+
...adapters: readonly TelemetryAdapter[]
|
|
282
|
+
): TelemetryAdapter {
|
|
283
|
+
return {
|
|
284
|
+
async emit(event) {
|
|
285
|
+
await Promise.allSettled(
|
|
286
|
+
adapters.map((adapter) =>
|
|
287
|
+
Promise.resolve().then(() => adapter.emit(event)),
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function createTelemetry(input: CreateTelemetryInput): Telemetry {
|
|
295
|
+
const service = normalizeName(input.service, 'Telemetry service');
|
|
296
|
+
const component = input.component?.trim() || null;
|
|
297
|
+
const context = normalizeContext(input.context);
|
|
298
|
+
const adapter = input.adapter ?? createConsoleTelemetryAdapter();
|
|
299
|
+
const now = input.now ?? (() => new Date());
|
|
300
|
+
|
|
301
|
+
function base(
|
|
302
|
+
event: string,
|
|
303
|
+
options?: TelemetryEventOptions,
|
|
304
|
+
): TelemetryEventBase {
|
|
305
|
+
const eventName = normalizeName(event, 'Telemetry event');
|
|
306
|
+
return {
|
|
307
|
+
telemetrySchemaVersion: TELEMETRY_SCHEMA_VERSION,
|
|
308
|
+
timestamp: now().toISOString(),
|
|
309
|
+
service,
|
|
310
|
+
component,
|
|
311
|
+
event: eventName,
|
|
312
|
+
tag: options?.tag?.trim() || `[${eventName}]`,
|
|
313
|
+
...context,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function publish(event: TelemetryEvent): Promise<void> {
|
|
318
|
+
try {
|
|
319
|
+
await adapter.emit(event);
|
|
320
|
+
} catch {
|
|
321
|
+
// Product execution must not depend on operational telemetry delivery.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function log(
|
|
326
|
+
level: TelemetryLevel,
|
|
327
|
+
event: string,
|
|
328
|
+
fields?: TelemetryFields,
|
|
329
|
+
options?: TelemetryEventOptions,
|
|
330
|
+
) {
|
|
331
|
+
await publish({
|
|
332
|
+
...base(event, options),
|
|
333
|
+
...normalizeFields(fields),
|
|
334
|
+
telemetryKind: 'log',
|
|
335
|
+
telemetryLevel: level,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function metric(
|
|
340
|
+
metricKind: TelemetryMetricKind,
|
|
341
|
+
name: string,
|
|
342
|
+
value: number,
|
|
343
|
+
unit: string | undefined,
|
|
344
|
+
fields: TelemetryFields | undefined,
|
|
345
|
+
) {
|
|
346
|
+
if (!Number.isFinite(value)) {
|
|
347
|
+
throw new Error(`Telemetry metric ${name} must have a finite value.`);
|
|
348
|
+
}
|
|
349
|
+
await publish({
|
|
350
|
+
...base(name),
|
|
351
|
+
...normalizeFields(fields),
|
|
352
|
+
telemetryKind: 'metric',
|
|
353
|
+
telemetryMetricKind: metricKind,
|
|
354
|
+
telemetryMetricValue: value,
|
|
355
|
+
telemetryMetricUnit: unit?.trim() || null,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
child(childInput) {
|
|
361
|
+
return createTelemetry({
|
|
362
|
+
service,
|
|
363
|
+
component: childInput.component ?? component ?? undefined,
|
|
364
|
+
context: { ...context, ...normalizeContext(childInput.context) },
|
|
365
|
+
adapter,
|
|
366
|
+
now,
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
debug: (event, fields, options) => log('debug', event, fields, options),
|
|
370
|
+
info: (event, fields, options) => log('info', event, fields, options),
|
|
371
|
+
warn: (event, fields, options) => log('warn', event, fields, options),
|
|
372
|
+
async error(event, error, fields, options) {
|
|
373
|
+
await publish({
|
|
374
|
+
...base(event, options),
|
|
375
|
+
...normalizeFields(fields),
|
|
376
|
+
telemetryKind: 'log',
|
|
377
|
+
telemetryLevel: 'error',
|
|
378
|
+
...errorFields(error),
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
counter: (name, value = 1, fields) =>
|
|
382
|
+
metric('counter', name, value, undefined, fields),
|
|
383
|
+
gauge: (name, value, fields) =>
|
|
384
|
+
metric('gauge', name, value, undefined, fields),
|
|
385
|
+
distribution: (name, value, options) =>
|
|
386
|
+
metric('distribution', name, value, options?.unit, options?.fields),
|
|
387
|
+
async time(span, fields, fn) {
|
|
388
|
+
const startedAt = performance.now();
|
|
389
|
+
try {
|
|
390
|
+
const result = await fn();
|
|
391
|
+
await publish({
|
|
392
|
+
...base(span),
|
|
393
|
+
...normalizeFields(fields),
|
|
394
|
+
telemetryKind: 'span',
|
|
395
|
+
telemetryLevel: 'info',
|
|
396
|
+
spanDurationMs: performance.now() - startedAt,
|
|
397
|
+
spanOutcome: 'ok',
|
|
398
|
+
});
|
|
399
|
+
return result;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
await publish({
|
|
402
|
+
...base(span),
|
|
403
|
+
...normalizeFields(fields),
|
|
404
|
+
telemetryKind: 'span',
|
|
405
|
+
telemetryLevel: 'error',
|
|
406
|
+
spanDurationMs: performance.now() - startedAt,
|
|
407
|
+
spanOutcome: 'error',
|
|
408
|
+
...errorFields(error),
|
|
409
|
+
});
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|