@telemetry-dev/sdk 0.1.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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/index.d.mts +184 -0
- package/dist/index.mjs +558 -0
- package/package.json +67 -0
- package/src/attrs.ts +157 -0
- package/src/capture.ts +39 -0
- package/src/client.ts +240 -0
- package/src/config.ts +104 -0
- package/src/index.ts +29 -0
- package/src/logs.ts +77 -0
- package/src/noop.ts +17 -0
- package/src/observe.ts +64 -0
- package/src/span.ts +272 -0
package/src/attrs.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Attributes, AttributeValue } from "@opentelemetry/api";
|
|
2
|
+
|
|
3
|
+
import { diag, jsonAttr, omitUndefined } from "@telemetry-dev/otel";
|
|
4
|
+
|
|
5
|
+
import { type CaptureConfig, prepareCaptureValue, truncate } from "./capture.ts";
|
|
6
|
+
|
|
7
|
+
export type SpanType = "span" | "generation" | "tool" | "agent" | "embedding";
|
|
8
|
+
|
|
9
|
+
// Operation names are taken verbatim by the ingest's deriveOperation. Plain spans always get an
|
|
10
|
+
// explicit "function": without one, the propagated gen_ai.conversation.id would trip the ingest's
|
|
11
|
+
// hasGenAi heuristic and misclassify them as "llm".
|
|
12
|
+
export const SPAN_TYPE_OPERATIONS: Record<SpanType, string> = {
|
|
13
|
+
span: "function",
|
|
14
|
+
generation: "chat",
|
|
15
|
+
tool: "execute_tool",
|
|
16
|
+
agent: "invoke_agent",
|
|
17
|
+
embedding: "embeddings",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function inputKeyFor(type: SpanType): string {
|
|
21
|
+
return type === "tool" ? "gen_ai.tool.call.arguments" : "gen_ai.input.messages";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function outputKeyFor(type: SpanType): string {
|
|
25
|
+
return type === "tool" ? "gen_ai.tool.call.result" : "gen_ai.output.messages";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TokenUsage {
|
|
29
|
+
inputTokens?: number;
|
|
30
|
+
outputTokens?: number;
|
|
31
|
+
totalTokens?: number;
|
|
32
|
+
cacheReadInputTokens?: number;
|
|
33
|
+
cacheCreationInputTokens?: number;
|
|
34
|
+
reasoningOutputTokens?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GenerationFields {
|
|
38
|
+
model?: string;
|
|
39
|
+
provider?: string;
|
|
40
|
+
systemInstructions?: unknown;
|
|
41
|
+
responseModel?: string;
|
|
42
|
+
responseId?: string;
|
|
43
|
+
usage?: TokenUsage;
|
|
44
|
+
/** Optional client-side cost override; the server computes cost from usage by default. */
|
|
45
|
+
costUsd?: number;
|
|
46
|
+
finishReason?: string;
|
|
47
|
+
outputType?: string;
|
|
48
|
+
temperature?: number;
|
|
49
|
+
topP?: number;
|
|
50
|
+
topK?: number;
|
|
51
|
+
maxTokens?: number;
|
|
52
|
+
stopSequences?: string[];
|
|
53
|
+
seed?: number;
|
|
54
|
+
frequencyPenalty?: number;
|
|
55
|
+
presencePenalty?: number;
|
|
56
|
+
timeToFirstChunkMs?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ToolFields {
|
|
60
|
+
toolName?: string;
|
|
61
|
+
toolCallId?: string;
|
|
62
|
+
toolDescription?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AgentFields {
|
|
66
|
+
agentName?: string;
|
|
67
|
+
agentId?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SpanFields extends GenerationFields, ToolFields, AgentFields {
|
|
71
|
+
/** Rename the span (update only). */
|
|
72
|
+
name?: string;
|
|
73
|
+
input?: unknown;
|
|
74
|
+
output?: unknown;
|
|
75
|
+
/** Marks the span failed: status ERROR + error.type + an "exception" event. */
|
|
76
|
+
error?: unknown;
|
|
77
|
+
/** td.metadata.<key> on this span only; use propagateAttributes for scope-wide metadata. */
|
|
78
|
+
metadata?: Record<string, unknown>;
|
|
79
|
+
/** Raw attribute escape hatch, merged last. */
|
|
80
|
+
attributes?: Record<string, AttributeValue>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FieldCaptureConfig extends CaptureConfig {
|
|
84
|
+
captureInput: boolean;
|
|
85
|
+
captureOutput: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const RESERVED_METADATA_KEYS = new Set(["userId", "sessionId", "user_id", "session_id"]);
|
|
89
|
+
|
|
90
|
+
export function fieldsToAttributes(
|
|
91
|
+
fields: SpanFields,
|
|
92
|
+
type: SpanType,
|
|
93
|
+
cfg: FieldCaptureConfig,
|
|
94
|
+
): Attributes {
|
|
95
|
+
const attrs = omitUndefined({
|
|
96
|
+
"gen_ai.request.model": fields.model,
|
|
97
|
+
"gen_ai.provider.name": fields.provider,
|
|
98
|
+
"gen_ai.response.model": fields.responseModel,
|
|
99
|
+
"gen_ai.response.id": fields.responseId,
|
|
100
|
+
"gen_ai.usage.input_tokens": fields.usage?.inputTokens,
|
|
101
|
+
"gen_ai.usage.output_tokens": fields.usage?.outputTokens,
|
|
102
|
+
"gen_ai.usage.total_tokens": fields.usage?.totalTokens,
|
|
103
|
+
"gen_ai.usage.cache_read.input_tokens": fields.usage?.cacheReadInputTokens,
|
|
104
|
+
"gen_ai.usage.cache_creation.input_tokens": fields.usage?.cacheCreationInputTokens,
|
|
105
|
+
"gen_ai.usage.reasoning.output_tokens": fields.usage?.reasoningOutputTokens,
|
|
106
|
+
"gen_ai.usage.cost": fields.costUsd,
|
|
107
|
+
"gen_ai.response.finish_reasons": fields.finishReason ? [fields.finishReason] : undefined,
|
|
108
|
+
"gen_ai.output.type": fields.outputType,
|
|
109
|
+
"gen_ai.request.temperature": fields.temperature,
|
|
110
|
+
"gen_ai.request.top_p": fields.topP,
|
|
111
|
+
"gen_ai.request.top_k": fields.topK,
|
|
112
|
+
"gen_ai.request.max_tokens": fields.maxTokens,
|
|
113
|
+
"gen_ai.request.stop_sequences": fields.stopSequences,
|
|
114
|
+
"gen_ai.request.seed": fields.seed,
|
|
115
|
+
"gen_ai.request.frequency_penalty": fields.frequencyPenalty,
|
|
116
|
+
"gen_ai.request.presence_penalty": fields.presencePenalty,
|
|
117
|
+
// The key carries no "ms" unit suffix, so the ingest reads the value as seconds.
|
|
118
|
+
"gen_ai.response.time_to_first_chunk":
|
|
119
|
+
fields.timeToFirstChunkMs !== undefined ? fields.timeToFirstChunkMs / 1000 : undefined,
|
|
120
|
+
"gen_ai.tool.name": fields.toolName,
|
|
121
|
+
"gen_ai.tool.call.id": fields.toolCallId,
|
|
122
|
+
"gen_ai.tool.description": fields.toolDescription,
|
|
123
|
+
"gen_ai.agent.name": fields.agentName,
|
|
124
|
+
"gen_ai.agent.id": fields.agentId,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (fields.systemInstructions !== undefined && cfg.captureInput) {
|
|
128
|
+
const value = prepareCaptureValue("gen_ai.system_instructions", fields.systemInstructions, cfg);
|
|
129
|
+
if (value !== undefined) attrs["gen_ai.system_instructions"] = value;
|
|
130
|
+
}
|
|
131
|
+
if (fields.input !== undefined && cfg.captureInput) {
|
|
132
|
+
const key = inputKeyFor(type);
|
|
133
|
+
const value = prepareCaptureValue(key, fields.input, cfg);
|
|
134
|
+
if (value !== undefined) attrs[key] = value;
|
|
135
|
+
}
|
|
136
|
+
if (fields.output !== undefined && cfg.captureOutput) {
|
|
137
|
+
const key = outputKeyFor(type);
|
|
138
|
+
const value = prepareCaptureValue(key, fields.output, cfg);
|
|
139
|
+
if (value !== undefined) attrs[key] = value;
|
|
140
|
+
}
|
|
141
|
+
if (fields.metadata) {
|
|
142
|
+
for (const [key, value] of Object.entries(fields.metadata)) {
|
|
143
|
+
if (RESERVED_METADATA_KEYS.has(key)) {
|
|
144
|
+
diag.debug(`metadata key "${key}" is reserved; pass it via propagateAttributes instead`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const attr = typeof value === "string" ? value : jsonAttr(value);
|
|
148
|
+
if (attr !== undefined) {
|
|
149
|
+
attrs[`td.metadata.${key}`] = truncate(attr, cfg.maxAttributeLength);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (fields.attributes) {
|
|
154
|
+
Object.assign(attrs, omitUndefined(fields.attributes));
|
|
155
|
+
}
|
|
156
|
+
return attrs;
|
|
157
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsonAttr, reportError } from "@telemetry-dev/otel";
|
|
2
|
+
|
|
3
|
+
import type { MaskFn } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
// Byte-identical with the Python SDK so truncated payloads are self-describing across languages.
|
|
6
|
+
export const TRUNCATION_MARKER = "...[truncated]";
|
|
7
|
+
|
|
8
|
+
export interface CaptureConfig {
|
|
9
|
+
mask?: MaskFn;
|
|
10
|
+
maxAttributeLength: number;
|
|
11
|
+
onError?: (error: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function truncate(value: string, maxLength: number): string {
|
|
15
|
+
if (value.length <= maxLength) return value;
|
|
16
|
+
// Total stays within maxLength so the provider's attributeValueLengthLimit backstop
|
|
17
|
+
// (set to the same cap) never slices the marker off.
|
|
18
|
+
return value.slice(0, Math.max(maxLength - TRUNCATION_MARKER.length, 0)) + TRUNCATION_MARKER;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The single content funnel: mask → JSON stringify → truncate. */
|
|
22
|
+
export function prepareCaptureValue(
|
|
23
|
+
key: string,
|
|
24
|
+
value: unknown,
|
|
25
|
+
cfg: CaptureConfig,
|
|
26
|
+
): string | undefined {
|
|
27
|
+
let masked = value;
|
|
28
|
+
if (cfg.mask) {
|
|
29
|
+
try {
|
|
30
|
+
masked = cfg.mask(value, { key });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
reportError(cfg.onError, error);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const serialized = jsonAttr(masked);
|
|
37
|
+
if (serialized === undefined) return undefined;
|
|
38
|
+
return truncate(serialized, cfg.maxAttributeLength);
|
|
39
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { context as apiContext, propagation, trace, type Tracer } from "@opentelemetry/api";
|
|
2
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
3
|
+
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
4
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
5
|
+
import {
|
|
6
|
+
BatchLogRecordProcessor,
|
|
7
|
+
LoggerProvider,
|
|
8
|
+
type LogRecordExporter,
|
|
9
|
+
SimpleLogRecordProcessor,
|
|
10
|
+
} from "@opentelemetry/sdk-logs";
|
|
11
|
+
import type { PushMetricExporter } from "@opentelemetry/sdk-metrics";
|
|
12
|
+
import {
|
|
13
|
+
BasicTracerProvider,
|
|
14
|
+
type ReadableSpan,
|
|
15
|
+
type SpanExporter,
|
|
16
|
+
} from "@opentelemetry/sdk-trace-base";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
als,
|
|
20
|
+
AlsContextManager,
|
|
21
|
+
BATCHED_METRIC_INTERVAL_MS,
|
|
22
|
+
createLogExporter,
|
|
23
|
+
createMetricExporter,
|
|
24
|
+
createMetricsPipeline,
|
|
25
|
+
createTraceExporter,
|
|
26
|
+
diag,
|
|
27
|
+
DORMANT_INTERVAL_MS,
|
|
28
|
+
otlpHeaders,
|
|
29
|
+
reportError,
|
|
30
|
+
SCOPE_NAME,
|
|
31
|
+
SCOPE_VERSION,
|
|
32
|
+
setLogLevel,
|
|
33
|
+
StampingSpanProcessor,
|
|
34
|
+
type Transport,
|
|
35
|
+
} from "@telemetry-dev/otel";
|
|
36
|
+
|
|
37
|
+
import { type ResolvedConfig, resolveConfig, type TelemetryOptions } from "./config.ts";
|
|
38
|
+
|
|
39
|
+
/** Test seam: inject in-memory exporters instead of the OTLP fetch transport. */
|
|
40
|
+
export interface ClientOverrides {
|
|
41
|
+
spanExporter?: SpanExporter;
|
|
42
|
+
logRecordExporter?: LogRecordExporter;
|
|
43
|
+
metricExporter?: PushMetricExporter;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TelemetryClient {
|
|
47
|
+
readonly enabled: boolean;
|
|
48
|
+
/** Never rejects; hands the export off to waitUntil when configured. */
|
|
49
|
+
flush(): Promise<void>;
|
|
50
|
+
/** Flush + teardown + unregister globals; the client becomes a no-op afterwards. */
|
|
51
|
+
shutdown(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ClientCore {
|
|
55
|
+
config: ResolvedConfig;
|
|
56
|
+
tracer: Tracer;
|
|
57
|
+
ensureLogger(): Logger | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ClientHandle extends TelemetryClient {
|
|
61
|
+
core?: ClientCore;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NOOP_CLIENT: ClientHandle = {
|
|
65
|
+
enabled: false,
|
|
66
|
+
flush: () => Promise.resolve(),
|
|
67
|
+
shutdown: () => Promise.resolve(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let activeClient: ClientHandle | undefined;
|
|
71
|
+
|
|
72
|
+
export function currentClient(): ClientHandle {
|
|
73
|
+
return activeClient ?? NOOP_CLIENT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function flush(): Promise<void> {
|
|
77
|
+
return currentClient().flush();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function shutdown(): Promise<void> {
|
|
81
|
+
return currentClient().shutdown();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function init(options?: TelemetryOptions, overrides?: ClientOverrides): TelemetryClient {
|
|
85
|
+
try {
|
|
86
|
+
return initInner(options, overrides);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
reportError(options?.onError, error);
|
|
89
|
+
activeClient = NOOP_CLIENT;
|
|
90
|
+
return NOOP_CLIENT;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): TelemetryClient {
|
|
95
|
+
const config = resolveConfig(options);
|
|
96
|
+
setLogLevel(config.logLevel);
|
|
97
|
+
|
|
98
|
+
if (activeClient && activeClient !== NOOP_CLIENT) {
|
|
99
|
+
diag.warn("init() called again; replacing the previous client");
|
|
100
|
+
// shutdown() synchronously releases global registrations before this init claims them.
|
|
101
|
+
void activeClient.shutdown().catch((error: unknown) => reportError(config.onError, error));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const enabled = config.enabled && Boolean(config.apiKey ?? overrides?.spanExporter);
|
|
105
|
+
if (!enabled) {
|
|
106
|
+
if (config.enabled && !config.apiKey) {
|
|
107
|
+
diag.debug("no api key (apiKey option or TELEMETRY_DEV_API_KEY); telemetry disabled");
|
|
108
|
+
}
|
|
109
|
+
activeClient = NOOP_CLIENT;
|
|
110
|
+
return NOOP_CLIENT;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resource = resourceFromAttributes({
|
|
114
|
+
"service.name": config.serviceName,
|
|
115
|
+
"deployment.environment.name": config.environment,
|
|
116
|
+
...config.resourceAttributes,
|
|
117
|
+
});
|
|
118
|
+
const transport: Transport = { fetchImpl: config.fetchImpl, onError: config.onError };
|
|
119
|
+
const headers = config.apiKey ? otlpHeaders(config.apiKey) : undefined;
|
|
120
|
+
|
|
121
|
+
const spanExporter =
|
|
122
|
+
overrides?.spanExporter ??
|
|
123
|
+
createTraceExporter({ url: `${config.baseUrl}/v1/traces`, headers: headers! }, transport);
|
|
124
|
+
|
|
125
|
+
const metricExporter =
|
|
126
|
+
overrides?.metricExporter ??
|
|
127
|
+
(headers
|
|
128
|
+
? createMetricExporter({ url: `${config.baseUrl}/v1/metrics`, headers }, transport)
|
|
129
|
+
: undefined);
|
|
130
|
+
const metrics = metricExporter
|
|
131
|
+
? createMetricsPipeline({
|
|
132
|
+
resource,
|
|
133
|
+
exporter: metricExporter,
|
|
134
|
+
exportIntervalMillis:
|
|
135
|
+
config.exportMode === "batched" ? BATCHED_METRIC_INTERVAL_MS : DORMANT_INTERVAL_MS,
|
|
136
|
+
})
|
|
137
|
+
: undefined;
|
|
138
|
+
|
|
139
|
+
const spanFilter =
|
|
140
|
+
config.spanFilter ??
|
|
141
|
+
(config.registerGlobal
|
|
142
|
+
? (span: ReadableSpan) => span.instrumentationScope.name === SCOPE_NAME
|
|
143
|
+
: undefined);
|
|
144
|
+
|
|
145
|
+
const processor = new StampingSpanProcessor({
|
|
146
|
+
exporter: spanExporter,
|
|
147
|
+
exportMode: config.exportMode,
|
|
148
|
+
batch: config.batch,
|
|
149
|
+
spanFilter,
|
|
150
|
+
recordMetrics: metrics ? (span) => metrics.record(span) : undefined,
|
|
151
|
+
onError: config.onError,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const provider = new BasicTracerProvider({
|
|
155
|
+
resource,
|
|
156
|
+
spanProcessors: [processor],
|
|
157
|
+
spanLimits: { attributeValueLengthLimit: config.maxAttributeLength },
|
|
158
|
+
});
|
|
159
|
+
const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
|
|
160
|
+
|
|
161
|
+
let registeredTrace = false;
|
|
162
|
+
let registeredContext = false;
|
|
163
|
+
let registeredPropagation = false;
|
|
164
|
+
if (config.registerGlobal) {
|
|
165
|
+
registeredTrace = trace.setGlobalTracerProvider(provider);
|
|
166
|
+
if (als) {
|
|
167
|
+
registeredContext = apiContext.setGlobalContextManager(new AlsContextManager(als));
|
|
168
|
+
}
|
|
169
|
+
registeredPropagation = propagation.setGlobalPropagator(new W3CTraceContextPropagator());
|
|
170
|
+
if (!registeredTrace) {
|
|
171
|
+
diag.warn(
|
|
172
|
+
"a global TracerProvider is already registered; attach the TelemetrySpanProcessor from '@telemetry-dev/otel' to your own provider instead",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const logExporter =
|
|
178
|
+
overrides?.logRecordExporter ??
|
|
179
|
+
(headers
|
|
180
|
+
? createLogExporter({ url: `${config.baseUrl}/v1/logs`, headers }, transport)
|
|
181
|
+
: undefined);
|
|
182
|
+
// Lazy: the log pipeline only exists once log() is first called.
|
|
183
|
+
let logs: { logger: Logger; forceFlush(): Promise<void>; shutdown(): Promise<void> } | undefined;
|
|
184
|
+
const ensureLogger = (): Logger | undefined => {
|
|
185
|
+
if (!logExporter) return undefined;
|
|
186
|
+
if (!logs) {
|
|
187
|
+
const logProcessor =
|
|
188
|
+
config.exportMode === "immediate"
|
|
189
|
+
? new SimpleLogRecordProcessor(logExporter)
|
|
190
|
+
: new BatchLogRecordProcessor(logExporter);
|
|
191
|
+
const loggerProvider = new LoggerProvider({
|
|
192
|
+
resource,
|
|
193
|
+
logRecordLimits: { attributeValueLengthLimit: config.maxAttributeLength },
|
|
194
|
+
processors: [logProcessor],
|
|
195
|
+
});
|
|
196
|
+
logs = {
|
|
197
|
+
logger: loggerProvider.getLogger(SCOPE_NAME, SCOPE_VERSION),
|
|
198
|
+
forceFlush: () => loggerProvider.forceFlush(),
|
|
199
|
+
shutdown: () => loggerProvider.shutdown(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return logs.logger;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
let torn = false;
|
|
206
|
+
const collect = (op: "forceFlush" | "shutdown"): Promise<void> => {
|
|
207
|
+
const parts: Promise<unknown>[] = [provider[op]()];
|
|
208
|
+
if (logs) parts.push(logs[op]());
|
|
209
|
+
if (metrics) parts.push(metrics[op]());
|
|
210
|
+
return Promise.all(parts)
|
|
211
|
+
.then(() => undefined)
|
|
212
|
+
.catch((error: unknown) => reportError(config.onError, error));
|
|
213
|
+
};
|
|
214
|
+
const handoff = (p: Promise<void>): Promise<void> => {
|
|
215
|
+
if (config.waitUntil) {
|
|
216
|
+
config.waitUntil(p);
|
|
217
|
+
return Promise.resolve();
|
|
218
|
+
}
|
|
219
|
+
return p;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handle: ClientHandle = {
|
|
223
|
+
get enabled() {
|
|
224
|
+
return !torn;
|
|
225
|
+
},
|
|
226
|
+
flush: () => (torn ? Promise.resolve() : handoff(collect("forceFlush"))),
|
|
227
|
+
shutdown: () => {
|
|
228
|
+
if (torn) return Promise.resolve();
|
|
229
|
+
torn = true;
|
|
230
|
+
if (registeredTrace) trace.disable();
|
|
231
|
+
if (registeredContext) apiContext.disable();
|
|
232
|
+
if (registeredPropagation) propagation.disable();
|
|
233
|
+
handle.core = undefined;
|
|
234
|
+
return handoff(collect("shutdown"));
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
handle.core = { config, tracer, ensureLogger };
|
|
238
|
+
activeClient = handle;
|
|
239
|
+
return handle;
|
|
240
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AttributeValue } from "@opentelemetry/api";
|
|
2
|
+
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base";
|
|
3
|
+
import {
|
|
4
|
+
type BatchOptions,
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
DEFAULT_BATCH,
|
|
7
|
+
type ExportMode,
|
|
8
|
+
resolveEnv,
|
|
9
|
+
type SdkLogLevel,
|
|
10
|
+
} from "@telemetry-dev/otel";
|
|
11
|
+
|
|
12
|
+
export type { BatchOptions, ExportMode, LogLevel, SdkLogLevel } from "@telemetry-dev/otel";
|
|
13
|
+
|
|
14
|
+
/** Redaction hook applied to captured content (input/output/log message) before JSON stringification. */
|
|
15
|
+
export type MaskFn = (value: unknown, ctx: { key: string }) => unknown;
|
|
16
|
+
|
|
17
|
+
export interface TelemetryOptions {
|
|
18
|
+
/** telemetry.dev ingest key (`td_live_…`). Falls back to `TELEMETRY_DEV_API_KEY`. When absent the SDK is a no-op. */
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
/** Ingest base URL. Falls back to `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
/** Deployment environment label. Falls back to `TELEMETRY_DEV_ENVIRONMENT`, then `production`. */
|
|
23
|
+
environment?: string;
|
|
24
|
+
/** Service name attached to every trace. Falls back to `OTEL_SERVICE_NAME`, then `unknown_service`. */
|
|
25
|
+
serviceName?: string;
|
|
26
|
+
/** Kill switch: `false` makes the SDK a complete no-op (tests). Default true. */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** Register this SDK's TracerProvider/context manager/propagator globally. Default false (isolated). */
|
|
29
|
+
registerGlobal?: boolean;
|
|
30
|
+
/** "batched" (default) buffers spans; "immediate" exports per span (serverless). */
|
|
31
|
+
exportMode?: ExportMode;
|
|
32
|
+
/** Capture span inputs (args, prompts) by default. Default true. */
|
|
33
|
+
captureInput?: boolean;
|
|
34
|
+
/** Capture span outputs (results, completions) by default. Default true. */
|
|
35
|
+
captureOutput?: boolean;
|
|
36
|
+
/** Redaction hook run on captured content before stringify/truncate. */
|
|
37
|
+
mask?: MaskFn;
|
|
38
|
+
/** Truncation cap (chars) for content attributes; also set as the provider's attributeValueLengthLimit. Default 65536. */
|
|
39
|
+
maxAttributeLength?: number;
|
|
40
|
+
batch?: BatchOptions;
|
|
41
|
+
/** Export filter. Default when registerGlobal: only spans from this SDK's instrumentation scope. */
|
|
42
|
+
spanFilter?: (span: ReadableSpan) => boolean;
|
|
43
|
+
/** Extra resource attributes merged onto service.name / deployment.environment.name. */
|
|
44
|
+
resourceAttributes?: Record<string, AttributeValue>;
|
|
45
|
+
/** SDK self-diagnostics console level. Default "warn"; "silent" disables. */
|
|
46
|
+
logLevel?: SdkLogLevel;
|
|
47
|
+
/** Injected fetch implementation. Defaults to `globalThis.fetch`. */
|
|
48
|
+
fetch?: typeof fetch;
|
|
49
|
+
/** Serverless extender (e.g. Cloudflare `ctx.waitUntil`). When provided, `flush()` does not await exports. */
|
|
50
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
51
|
+
/** Receives any error raised while emitting telemetry; the SDK never throws into user code. */
|
|
52
|
+
onError?: (error: unknown) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolvedConfig {
|
|
56
|
+
apiKey: string | undefined;
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
environment: string;
|
|
59
|
+
serviceName: string;
|
|
60
|
+
enabled: boolean;
|
|
61
|
+
registerGlobal: boolean;
|
|
62
|
+
exportMode: ExportMode;
|
|
63
|
+
captureInput: boolean;
|
|
64
|
+
captureOutput: boolean;
|
|
65
|
+
mask?: MaskFn;
|
|
66
|
+
maxAttributeLength: number;
|
|
67
|
+
batch: Required<BatchOptions>;
|
|
68
|
+
spanFilter?: (span: ReadableSpan) => boolean;
|
|
69
|
+
resourceAttributes?: Record<string, AttributeValue>;
|
|
70
|
+
logLevel: SdkLogLevel;
|
|
71
|
+
fetchImpl: typeof fetch;
|
|
72
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
73
|
+
onError?: (error: unknown) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536;
|
|
77
|
+
|
|
78
|
+
export function resolveConfig(options: TelemetryOptions = {}): ResolvedConfig {
|
|
79
|
+
const env = resolveEnv();
|
|
80
|
+
const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(
|
|
81
|
+
/\/+$/,
|
|
82
|
+
"",
|
|
83
|
+
);
|
|
84
|
+
return {
|
|
85
|
+
apiKey: options.apiKey ?? env.TELEMETRY_DEV_API_KEY,
|
|
86
|
+
baseUrl,
|
|
87
|
+
environment: options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
|
|
88
|
+
serviceName: options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
|
|
89
|
+
enabled: options.enabled ?? true,
|
|
90
|
+
registerGlobal: options.registerGlobal ?? false,
|
|
91
|
+
exportMode: options.exportMode ?? "batched",
|
|
92
|
+
captureInput: options.captureInput ?? true,
|
|
93
|
+
captureOutput: options.captureOutput ?? true,
|
|
94
|
+
mask: options.mask,
|
|
95
|
+
maxAttributeLength: options.maxAttributeLength ?? DEFAULT_MAX_ATTRIBUTE_LENGTH,
|
|
96
|
+
batch: { ...DEFAULT_BATCH, ...options.batch },
|
|
97
|
+
spanFilter: options.spanFilter,
|
|
98
|
+
resourceAttributes: options.resourceAttributes,
|
|
99
|
+
logLevel: options.logLevel ?? "warn",
|
|
100
|
+
fetchImpl: options.fetch ?? globalThis.fetch,
|
|
101
|
+
waitUntil: options.waitUntil,
|
|
102
|
+
onError: options.onError,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AgentFields,
|
|
3
|
+
GenerationFields,
|
|
4
|
+
SpanFields,
|
|
5
|
+
SpanType,
|
|
6
|
+
TokenUsage,
|
|
7
|
+
ToolFields,
|
|
8
|
+
} from "./attrs.ts";
|
|
9
|
+
export { type ClientOverrides, flush, init, shutdown, type TelemetryClient } from "./client.ts";
|
|
10
|
+
export type {
|
|
11
|
+
BatchOptions,
|
|
12
|
+
ExportMode,
|
|
13
|
+
LogLevel,
|
|
14
|
+
MaskFn,
|
|
15
|
+
SdkLogLevel,
|
|
16
|
+
TelemetryOptions,
|
|
17
|
+
} from "./config.ts";
|
|
18
|
+
export { propagateAttributes, type PropagatedAttributes } from "@telemetry-dev/otel";
|
|
19
|
+
export { log, type LogOptions } from "./logs.ts";
|
|
20
|
+
export { observe, type ObserveOptions } from "./observe.ts";
|
|
21
|
+
export {
|
|
22
|
+
getTraceparent,
|
|
23
|
+
type ParentRef,
|
|
24
|
+
type SpanHandle,
|
|
25
|
+
startActiveSpan,
|
|
26
|
+
startSpan,
|
|
27
|
+
type StartSpanOptions,
|
|
28
|
+
updateActiveSpan,
|
|
29
|
+
} from "./span.ts";
|
package/src/logs.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AttributeValue } from "@opentelemetry/api";
|
|
2
|
+
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
3
|
+
|
|
4
|
+
import { activeContext, propagatedFromContext, reportError } from "@telemetry-dev/otel";
|
|
5
|
+
|
|
6
|
+
import { prepareCaptureValue, truncate } from "./capture.ts";
|
|
7
|
+
import { currentClient } from "./client.ts";
|
|
8
|
+
import type { LogLevel } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
// Severity numbers match the ingest's severityFromNumber buckets exactly.
|
|
11
|
+
const SEVERITIES: Record<LogLevel, { number: SeverityNumber; text: string }> = {
|
|
12
|
+
debug: { number: SeverityNumber.DEBUG, text: "DEBUG" },
|
|
13
|
+
info: { number: SeverityNumber.INFO, text: "INFO" },
|
|
14
|
+
warn: { number: SeverityNumber.WARN, text: "WARN" },
|
|
15
|
+
error: { number: SeverityNumber.ERROR, text: "ERROR" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface LogOptions {
|
|
19
|
+
/** Default "info". */
|
|
20
|
+
level?: LogLevel;
|
|
21
|
+
attributes?: Record<string, unknown>;
|
|
22
|
+
eventName?: string;
|
|
23
|
+
timestamp?: Date | number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Emit a log record to /v1/logs, correlated to the active span (when any) and stamped with the
|
|
28
|
+
* propagated correlation attributes. Works inside and outside spans.
|
|
29
|
+
*/
|
|
30
|
+
export function log(message: string, options?: LogOptions): void {
|
|
31
|
+
const core = currentClient().core;
|
|
32
|
+
if (!core) return;
|
|
33
|
+
try {
|
|
34
|
+
const logger = core.ensureLogger();
|
|
35
|
+
if (!logger) return;
|
|
36
|
+
const cfg = core.config;
|
|
37
|
+
const captureCfg = {
|
|
38
|
+
mask: cfg.mask,
|
|
39
|
+
maxAttributeLength: cfg.maxAttributeLength,
|
|
40
|
+
onError: cfg.onError,
|
|
41
|
+
};
|
|
42
|
+
const severity = SEVERITIES[options?.level ?? "info"];
|
|
43
|
+
const ctx = activeContext();
|
|
44
|
+
|
|
45
|
+
const attributes: Record<string, AttributeValue> = {};
|
|
46
|
+
for (const [key, value] of Object.entries(options?.attributes ?? {})) {
|
|
47
|
+
if (value === undefined) continue;
|
|
48
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
49
|
+
attributes[key] = value;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const prepared = prepareCaptureValue(key, value, captureCfg);
|
|
53
|
+
if (prepared !== undefined) attributes[key] = prepared;
|
|
54
|
+
}
|
|
55
|
+
const propagated = propagatedFromContext(ctx);
|
|
56
|
+
if (propagated) {
|
|
57
|
+
for (const [key, value] of Object.entries(propagated)) {
|
|
58
|
+
if (value !== undefined) {
|
|
59
|
+
attributes[key] =
|
|
60
|
+
typeof value === "string" ? truncate(value, cfg.maxAttributeLength) : value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.emit({
|
|
66
|
+
body: prepareCaptureValue("log.message", message, captureCfg) ?? "",
|
|
67
|
+
severityNumber: severity.number,
|
|
68
|
+
severityText: severity.text,
|
|
69
|
+
eventName: options?.eventName,
|
|
70
|
+
timestamp: options?.timestamp,
|
|
71
|
+
context: ctx,
|
|
72
|
+
attributes,
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
reportError(core.config.onError, error);
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/noop.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { INVALID_SPAN_CONTEXT, ROOT_CONTEXT, trace } from "@opentelemetry/api";
|
|
2
|
+
|
|
3
|
+
import type { SpanHandle } from "./span.ts";
|
|
4
|
+
|
|
5
|
+
const noopSpan = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
6
|
+
|
|
7
|
+
/** Shared inert handle returned by span primitives before init() or when disabled. */
|
|
8
|
+
export const NOOP_SPAN_HANDLE: SpanHandle = {
|
|
9
|
+
span: noopSpan,
|
|
10
|
+
context: trace.setSpan(ROOT_CONTEXT, noopSpan),
|
|
11
|
+
traceId: INVALID_SPAN_CONTEXT.traceId,
|
|
12
|
+
spanId: INVALID_SPAN_CONTEXT.spanId,
|
|
13
|
+
traceparent: null,
|
|
14
|
+
isRecording: false,
|
|
15
|
+
update: () => NOOP_SPAN_HANDLE,
|
|
16
|
+
end: () => {},
|
|
17
|
+
};
|