@telemetry-dev/sdk 0.1.10 → 0.1.11

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/README.md CHANGED
@@ -111,6 +111,69 @@ The default is parent-based always-on. Session roots keep the configured root po
111
111
  Nested spans and explicitly parented spans keep the real parent's policy.
112
112
  The SDK installs the [session sampling wrapper](https://github.com/telemetry-dev/telemetry/blob/main/packages/otel/README.md#correlation-attributes) on its provider. This includes the global provider that Eve uses.
113
113
 
114
+ ## Prompts
115
+
116
+ `getPrompt` fetches a prompt that you manage in telemetry.dev. Fetch it by its label, so that you
117
+ deploy a version when you move the label, without a change to the code:
118
+
119
+ ```ts
120
+ import OpenAI from "openai";
121
+ import { getPrompt, init, withPrompt } from "@telemetry-dev/sdk";
122
+ import { wrapOpenAI } from "@telemetry-dev/openai";
123
+
124
+ init();
125
+ const openai = wrapOpenAI(new OpenAI());
126
+
127
+ const prompt = await getPrompt("support/reply", { label: "production" });
128
+ const messages = prompt.compile({ customer_message, tone });
129
+
130
+ const response = await withPrompt(prompt, () =>
131
+ openai.chat.completions.create({ model: prompt.model?.name ?? "gpt-4o", messages }),
132
+ );
133
+
134
+ // Pin one version, as in a test:
135
+ const pinned = await getPrompt("support/reply", { version: 13 });
136
+ ```
137
+
138
+ - Without `label` and `version`, getPrompt fetches the `production` label. A text prompt needs
139
+ `type: "text"`; getPrompt rejects a prompt of the other type.
140
+ - `compile(variables)` puts the values in place of `{{name}}`. A missing value becomes an empty
141
+ text, and a value that is not a string becomes JSON. In a chat prompt, a placeholder message
142
+ becomes the list of messages in its variable. A text prompt compiles to a string.
143
+ - The prompt also has `model`, `parameters`, `tools`, and `responseFormat` of its version.
144
+ - getPrompt keeps each prompt. When the copy is older than `cacheTtlSeconds` (default 60), a call
145
+ returns it at once and fetches a new copy in the background. Only a call with no copy waits for a
146
+ fetch, for 10 seconds at most. Calls for the same prompt share one fetch.
147
+ - If a fetch fails, getPrompt returns the last copy that it fetched and reports the error through
148
+ `onError`. With no copy, it returns the `fallback` option (`version` is `null` and `isFallback`
149
+ is `true`). With no fallback, it rejects with a `PromptError` whose `code` tells why, for
150
+ example `prompt_not_found`, `label_not_found`, `insufficient_scope`, `missing_api_key`, or
151
+ `timeout`. A response with a different status and no API error gives `http_<status>`, for
152
+ example `http_302` for a redirect. getPrompt does not send the request to the location of a
153
+ redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
154
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. This is the one SDK
155
+ function that can reject: your app cannot run without its prompt.
156
+ - getPrompt uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY` and
157
+ `TELEMETRY_DEV_BASE_URL`, or its own `apiKey`, `baseUrl`, and `fetch` options. It reads the same
158
+ host that telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read
159
+ scope for prompts.
160
+ - When a label sends part of the traffic to a second version, each session keeps one version: the
161
+ choice uses the session id of `propagateAttributes` (or of `sessionMode: "process"`). Without a
162
+ session id, each call chooses at random.
163
+
164
+ `withPrompt(prompt, fn)` records the prompt on each model call that starts inside `fn`:
165
+ `td.prompt.name`, `td.prompt.version`, and `td.prompt.label` (only for a prompt fetched by label).
166
+ When input capture is on, `td.prompt.variables` holds the JSON of the variables of the last
167
+ `compile()` of that prompt object before `withPrompt` starts, or of the last `compile()` inside
168
+ `fn`, after `mask` and truncation. If tasks that run at the same time share one prompt object, call
169
+ `compile()` in `withPrompt`, or call getPrompt in each task. getPrompt returns a new object from
170
+ the cache. If you do not, a task can record the variables of a different task. Model calls are the
171
+ spans of type `generation` (operations `chat`, `text_completion`, and `generate_content`),
172
+ including the spans of the provider integrations that use this SDK: OpenAI, Anthropic, Amazon
173
+ Bedrock, Google GenAI, and OpenRouter. Spans from the AI SDK and TanStack AI integrations, and from
174
+ other OpenTelemetry tracers, do not get the attributes. Messages from `compile()` that you pass to
175
+ a model call outside `withPrompt` are not recorded.
176
+
114
177
  ## API
115
178
 
116
179
  - `init(options?)` — create the client (isolated OTel TracerProvider by default).
@@ -130,6 +193,8 @@ The SDK installs the [session sampling wrapper](https://github.com/telemetry-dev
130
193
  - `updateActiveSpan(fields)` — update the innermost active span.
131
194
  - `propagateAttributes({ userId, sessionId, metadata }, fn)` — stamps `user.id`,
132
195
  `gen_ai.conversation.id`, and `td.metadata.*` on every span and log record in scope.
196
+ - `getPrompt(name, options?)` — fetch a prompt by label or version; see [Prompts](#prompts).
197
+ - `withPrompt(prompt, fn)` — record the prompt on each model call in `fn`.
133
198
  - `log(message, { level, eventName, attributes }?)` — log record correlated to the active trace.
134
199
  - `getTraceparent()` — W3C traceparent of the active span (pass to other services; accept it via
135
200
  `startSpan(name, { parent })`).
package/dist/index.d.mts CHANGED
@@ -16,7 +16,7 @@ type MaskFn<T = unknown> = (value: T, ctx: {
16
16
  interface TelemetryOptions {
17
17
  /** telemetry.dev ingest key (`td_live_…`). Falls back to `TELEMETRY_DEV_API_KEY`. When absent the SDK is a no-op. */
18
18
  apiKey?: string;
19
- /** Ingest base URL. Falls back to `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
19
+ /** Base URL for telemetry export and getPrompt. Falls back to `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
20
20
  baseUrl?: string;
21
21
  /** Deployment environment label. Falls back to `TELEMETRY_DEV_ENVIRONMENT`, then `production`. */
22
22
  environment?: string;
@@ -257,4 +257,157 @@ interface ObserveOptions extends StartSpanOptions {
257
257
  */
258
258
  declare function observe<TThis, TArgs extends unknown[], TReturn>(fn: (this: TThis, ...args: TArgs) => TReturn, options?: ObserveOptions): (...args: TArgs) => TReturn;
259
259
  //#endregion
260
- export { type AgentFields, type BatchOptions, type BoundedCaptureDetails, type BoundedCaptureOptions, type BoundedCaptureResult, type ClientOverrides, type ExportMode, type GenerationFields, type LogLevel, type LogOptions, type MaskFn, type ObserveOptions, type ParentRef, type PropagatedAttributes, SCOPE_NAME, type SdkLogLevel, type SpanCapturePolicy, type SpanFields, type SpanHandle, type SpanType, type StartSpanOptions, type TelemetryClient, type TelemetryOptions, TelemetrySpanProcessor, type TelemetrySpanProcessorOptions, type TokenUsage, type ToolFields, activeContext, boundedCapture, boundedCaptureDetails, captureEnabled, captureMasked, extractW3cContext, flush, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext };
260
+ //#region src/prompts.d.ts
261
+ /** A message of a chat prompt. A placeholder stands for a list of messages that the app passes. */
262
+ type PromptMessage = {
263
+ readonly role: "system" | "user" | "assistant";
264
+ readonly content: string;
265
+ } | {
266
+ readonly role: "placeholder";
267
+ readonly name: string;
268
+ };
269
+ /** A message that `compile()` makes from a prompt message. */
270
+ interface ChatMessage {
271
+ role: "system" | "user" | "assistant";
272
+ content: string;
273
+ }
274
+ interface PromptModel {
275
+ readonly provider: string;
276
+ readonly name: string;
277
+ }
278
+ /** The parameters that are set in the version. A parameter that is not set is absent. */
279
+ interface PromptParameters {
280
+ readonly temperature?: number;
281
+ readonly topP?: number;
282
+ readonly maxTokens?: number;
283
+ readonly frequencyPenalty?: number;
284
+ readonly presencePenalty?: number;
285
+ readonly stop?: readonly string[];
286
+ readonly seed?: number;
287
+ readonly toolChoice?: "auto" | "none" | "required" | {
288
+ readonly tool: string;
289
+ };
290
+ readonly parallelToolCalls?: boolean;
291
+ }
292
+ interface PromptTool {
293
+ readonly name: string;
294
+ readonly description: string;
295
+ /** JSON schema of the tool arguments. */
296
+ readonly parameters: Readonly<Record<string, unknown>>;
297
+ readonly strict: boolean;
298
+ }
299
+ type PromptResponseFormat = {
300
+ readonly type: "text";
301
+ } | {
302
+ readonly type: "json_object";
303
+ } | {
304
+ readonly type: "json_schema";
305
+ readonly name: string;
306
+ readonly schema: Readonly<Record<string, unknown>>;
307
+ readonly strict: boolean;
308
+ };
309
+ /**
310
+ * Values for `{{name}}` variables. A missing value, null, or undefined becomes an empty text, and a
311
+ * value that is not a string becomes JSON. A placeholder message takes a list of messages.
312
+ */
313
+ type PromptVariables<M = ChatMessage> = Readonly<Record<string, string | number | boolean | null | undefined | readonly M[]>>;
314
+ interface PromptBase {
315
+ readonly name: string;
316
+ /** The version that the app got; null for the fallback. */
317
+ readonly version: number | null;
318
+ /** The label that the prompt was fetched by; null when it was fetched by version. */
319
+ readonly label: string | null;
320
+ /** Null for the fallback. */
321
+ readonly model: PromptModel | null;
322
+ readonly parameters: PromptParameters;
323
+ readonly tools: readonly PromptTool[];
324
+ readonly responseFormat: PromptResponseFormat;
325
+ /** True when getPrompt could not fetch the prompt and returned the `fallback` option. */
326
+ readonly isFallback: boolean;
327
+ }
328
+ interface ChatPrompt extends PromptBase {
329
+ readonly type: "chat";
330
+ readonly messages: readonly PromptMessage[];
331
+ readonly text: null;
332
+ /**
333
+ * Fills in the variables and replaces each placeholder message with the messages in its
334
+ * variable. `withPrompt` records the variables of the last call before it starts or inside it.
335
+ */
336
+ compile<M = ChatMessage>(variables?: PromptVariables<M>): (ChatMessage | M)[];
337
+ }
338
+ interface TextPrompt extends PromptBase {
339
+ readonly type: "text";
340
+ readonly messages: readonly [];
341
+ readonly text: string;
342
+ /**
343
+ * Fills in the variables. `withPrompt` records the variables of the last call before it starts
344
+ * or inside it.
345
+ */
346
+ compile(variables?: PromptVariables): string;
347
+ }
348
+ type Prompt = ChatPrompt | TextPrompt;
349
+ interface GetPromptOptionsBase {
350
+ /** Fetch the version that this label points to. Default "production" when `version` is not set. */
351
+ label?: string;
352
+ /** Fetch this version number. Do not set it together with `label`. */
353
+ version?: number;
354
+ /** Seconds before a call fetches a kept prompt again, in the background. Default 60. */
355
+ cacheTtlSeconds?: number;
356
+ /** API key with the prompts:read scope. Falls back to the initialized client, then `TELEMETRY_DEV_API_KEY`. */
357
+ apiKey?: string;
358
+ /** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
359
+ baseUrl?: string;
360
+ /** Falls back to the initialized client's `fetch`, then `globalThis.fetch`. */
361
+ fetch?: typeof fetch;
362
+ }
363
+ interface GetChatPromptOptions extends GetPromptOptionsBase {
364
+ /** Default "chat". getPrompt rejects a prompt of the other type. */
365
+ type?: "chat";
366
+ /** Messages to use when the prompt cannot be fetched and no copy of it is kept. */
367
+ fallback?: readonly PromptMessage[];
368
+ }
369
+ interface GetTextPromptOptions extends GetPromptOptionsBase {
370
+ type: "text";
371
+ /** Text to use when the prompt cannot be fetched and no copy of it is kept. */
372
+ fallback?: string;
373
+ }
374
+ type GetPromptOptions = GetChatPromptOptions | GetTextPromptOptions;
375
+ /**
376
+ * Why getPrompt failed. `code` is the code of the API error (`prompt_not_found`,
377
+ * `label_not_found`, `insufficient_scope`, `invalid_api_key`, …), or `missing_api_key`,
378
+ * `timeout`, `network_error`, `invalid_response`, or `type_mismatch`. A response with a different
379
+ * status and no API error gives `http_<status>`, for example `http_302` for a redirect: getPrompt
380
+ * does not go to the location of a redirect.
381
+ */
382
+ declare class PromptError extends Error {
383
+ readonly code: string;
384
+ readonly status?: number | undefined;
385
+ readonly name = "PromptError";
386
+ constructor(code: string, message: string, status?: number | undefined, options?: {
387
+ cause?: unknown;
388
+ });
389
+ }
390
+ /**
391
+ * Fetches a prompt version from telemetry.dev and keeps it. When the copy is older than
392
+ * `cacheTtlSeconds`, a call returns it at once and fetches a new one in the background. Only a call
393
+ * with no copy waits for a fetch, at most 10 seconds. Calls for the same prompt share one fetch.
394
+ * When a fetch fails, getPrompt returns the last copy that it kept; with no copy it returns
395
+ * `fallback`. With neither it rejects with a `PromptError`: unlike the telemetry functions,
396
+ * getPrompt cannot fail open, because the app cannot run without the prompt. With no copy, the
397
+ * calls of the next 10 seconds (or the TTL, if it is shorter) fail the same way without a fetch.
398
+ *
399
+ * A label with a gradual rollout gives each session one version: the choice uses the session id
400
+ * of `propagateAttributes`, or a random choice per call without one.
401
+ */
402
+ declare function getPrompt(name: string, options: GetTextPromptOptions): Promise<TextPrompt>;
403
+ declare function getPrompt(name: string, options?: GetChatPromptOptions): Promise<ChatPrompt>;
404
+ /**
405
+ * Runs `fn` with `prompt` as the prompt of each model call (generation span) that starts inside
406
+ * it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
407
+ * capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
408
+ * prompt object before withPrompt starts, or of the last `compile()` inside `fn`, so a compile in
409
+ * another task does not change them once withPrompt runs.
410
+ */
411
+ declare function withPrompt<T>(prompt: Prompt, fn: () => T): T;
412
+ //#endregion
413
+ export { type AgentFields, type BatchOptions, type BoundedCaptureDetails, type BoundedCaptureOptions, type BoundedCaptureResult, type ChatMessage, type ChatPrompt, type ClientOverrides, type ExportMode, type GenerationFields, type GetChatPromptOptions, type GetPromptOptions, type GetTextPromptOptions, type LogLevel, type LogOptions, type MaskFn, type ObserveOptions, type ParentRef, type Prompt, PromptError, type PromptMessage, type PromptModel, type PromptParameters, type PromptResponseFormat, type PromptTool, type PromptVariables, type PropagatedAttributes, SCOPE_NAME, type SdkLogLevel, type SpanCapturePolicy, type SpanFields, type SpanHandle, type SpanType, type StartSpanOptions, type TelemetryClient, type TelemetryOptions, TelemetrySpanProcessor, type TelemetrySpanProcessorOptions, type TextPrompt, type TokenUsage, type ToolFields, activeContext, boundedCapture, boundedCaptureDetails, captureEnabled, captureMasked, extractW3cContext, flush, getPrompt, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext, withPrompt };
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { INVALID_SPAN_CONTEXT, ROOT_CONTEXT, SpanKind, SpanStatusCode, context, defaultTextMapGetter, defaultTextMapSetter, propagation, trace } from "@opentelemetry/api";
1
+ import { INVALID_SPAN_CONTEXT, ROOT_CONTEXT, SpanKind, SpanStatusCode, context, createContextKey, defaultTextMapGetter, defaultTextMapSetter, propagation, trace } from "@opentelemetry/api";
2
2
  import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator, parseTraceParent } from "@opentelemetry/core";
3
3
  import { resourceFromAttributes } from "@opentelemetry/resources";
4
4
  import { BatchLogRecordProcessor, LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
5
5
  import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
6
- import { AlsContextManager, BATCHED_METRIC_INTERVAL_MS, DEFAULT_BASE_URL, DEFAULT_BATCH, DORMANT_INTERVAL_MS, DURATION_BUCKETS, OUTPUT_CHUNK_HISTOGRAM, PROPAGATED_KEY, SCOPE_NAME, SCOPE_NAME as SCOPE_NAME$1, SCOPE_VERSION, StampingSpanProcessor, TelemetrySpanProcessor, activeContext, activeContext as activeContext$1, als, createExportFailures, createLogExporter, createMetricExporter, createMetricsPipeline, createTraceExporter, diag, jsonAttr, omitUndefined, otlpHeaders, propagateAttributes, propagatedFromContext, reportError, resolveEnv, sessionIdOf, sessionRootTracerProvider, sessionSampler, setLogLevel, withContext, withContext as withContext$1, withSessionParent } from "@telemetry-dev/otel";
6
+ import { AlsContextManager, BATCHED_METRIC_INTERVAL_MS, DEFAULT_BASE_URL, DEFAULT_BATCH, DORMANT_INTERVAL_MS, DURATION_BUCKETS, OUTPUT_CHUNK_HISTOGRAM, PROPAGATED_KEY, SCOPE_NAME, SCOPE_NAME as SCOPE_NAME$1, SCOPE_VERSION, StampingSpanProcessor, TelemetrySpanProcessor, activeContext, activeContext as activeContext$1, als, createExportFailures, createLogExporter, createMetricExporter, createMetricsPipeline, createTraceExporter, diag, jsonAttr, omitUndefined, otlpHeaders, propagateAttributes, propagatedFromContext, reportError, resolveEnv, sessionIdOf, sessionRootTracerProvider, sessionSampler, setLogLevel, sha256, withContext, withContext as withContext$1, withSessionParent } from "@telemetry-dev/otel";
7
7
  import { SeverityNumber } from "@opentelemetry/api-logs";
8
8
  //#region src/config.ts
9
9
  const DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536;
@@ -546,6 +546,303 @@ const NOOP_SPAN_HANDLE = {
546
546
  end: () => {}
547
547
  };
548
548
  //#endregion
549
+ //#region src/prompts.ts
550
+ /**
551
+ * Why getPrompt failed. `code` is the code of the API error (`prompt_not_found`,
552
+ * `label_not_found`, `insufficient_scope`, `invalid_api_key`, …), or `missing_api_key`,
553
+ * `timeout`, `network_error`, `invalid_response`, or `type_mismatch`. A response with a different
554
+ * status and no API error gives `http_<status>`, for example `http_302` for a redirect: getPrompt
555
+ * does not go to the location of a redirect.
556
+ */
557
+ var PromptError = class extends Error {
558
+ name = "PromptError";
559
+ constructor(code, message, status, options) {
560
+ super(message, options);
561
+ this.code = code;
562
+ this.status = status;
563
+ }
564
+ };
565
+ const FETCH_TIMEOUT_MS = 1e4;
566
+ /** How long a failed fetch with no copy answers calls, so that they do not each fetch again. */
567
+ const ERROR_TTL_MS = 1e4;
568
+ const MAX_CACHE_ENTRIES = 1e3;
569
+ const VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
570
+ const MODEL_CALL_OPERATIONS = new Set([
571
+ "chat",
572
+ "text_completion",
573
+ "generate_content"
574
+ ]);
575
+ const PROMPT_KEY = createContextKey("telemetry.dev prompt");
576
+ const cache = /* @__PURE__ */ new Map();
577
+ const compiledVariables = /* @__PURE__ */ new WeakMap();
578
+ async function getPrompt(name, options = {}) {
579
+ const type = options.type ?? "chat";
580
+ checkOptions(name, options, type);
581
+ const core = currentClient().core;
582
+ const env = resolveEnv();
583
+ const apiKey = options.apiKey ?? core?.config.apiKey ?? env.TELEMETRY_DEV_API_KEY;
584
+ const baseUrl = (options.baseUrl || core?.config.baseUrl || env.TELEMETRY_DEV_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
585
+ const label = options.version === void 0 ? options.label ?? "production" : null;
586
+ const onError = core?.config.onError;
587
+ let response;
588
+ try {
589
+ if (!apiKey) throw new PromptError("missing_api_key", "No API key: pass apiKey or set TELEMETRY_DEV_API_KEY");
590
+ const url = new URL(`${baseUrl}/v1/prompts`);
591
+ url.searchParams.set("name", name);
592
+ if (label === null) url.searchParams.set("version", String(options.version));
593
+ else url.searchParams.set("label", label);
594
+ response = await load([
595
+ baseUrl,
596
+ keyFingerprint(apiKey),
597
+ name,
598
+ label === null ? `version:${options.version}` : `label:${label}`
599
+ ].join("\0"), (options.cacheTtlSeconds ?? 60) * 1e3, () => fetchPrompt(url, {
600
+ authorization: `Bearer ${apiKey}`,
601
+ "x-telemetry-dev-sdk": core?.config.sdkName ?? "@telemetry-dev/sdk"
602
+ }, options.fetch ?? core?.config.fetchImpl ?? globalThis.fetch), onError);
603
+ } catch (error) {
604
+ if (options.fallback === void 0) throw error;
605
+ reportError(onError, error);
606
+ return createPrompt({
607
+ name,
608
+ type,
609
+ label,
610
+ version: null,
611
+ isFallback: true,
612
+ content: {
613
+ messages: type === "chat" ? options.fallback : [],
614
+ text: type === "text" ? options.fallback : null,
615
+ model: null,
616
+ parameters: {},
617
+ tools: [],
618
+ responseFormat: { type: "text" }
619
+ }
620
+ });
621
+ }
622
+ if (response.type !== type) throw new PromptError("type_mismatch", `${name} is a ${response.type} prompt; pass type: "${response.type}"`);
623
+ const chosen = response.rollout && inRollout(response) ? response.rollout : response;
624
+ return createPrompt({
625
+ name: response.name,
626
+ type,
627
+ label: response.label,
628
+ version: chosen.version,
629
+ isFallback: false,
630
+ content: chosen
631
+ });
632
+ }
633
+ /**
634
+ * Runs `fn` with `prompt` as the prompt of each model call (generation span) that starts inside
635
+ * it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
636
+ * capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
637
+ * prompt object before withPrompt starts, or of the last `compile()` inside `fn`, so a compile in
638
+ * another task does not change them once withPrompt runs.
639
+ */
640
+ function withPrompt(prompt, fn) {
641
+ const scope = {
642
+ prompt,
643
+ variables: compiledVariables.get(prompt)
644
+ };
645
+ return withContext$1(activeContext$1().setValue(PROMPT_KEY, scope), fn);
646
+ }
647
+ /** The td.prompt.* attributes for a span with this operation that starts in `ctx`. */
648
+ function promptAttributes(ctx, operation, capture) {
649
+ if (!MODEL_CALL_OPERATIONS.has(operation)) return void 0;
650
+ const scope = ctx.getValue(PROMPT_KEY) ?? activeContext$1().getValue(PROMPT_KEY);
651
+ if (!scope) return void 0;
652
+ const { prompt, variables } = scope;
653
+ return omitUndefined({
654
+ "td.prompt.name": prompt.name,
655
+ "td.prompt.version": prompt.version ?? void 0,
656
+ "td.prompt.label": prompt.label ?? void 0,
657
+ "td.prompt.variables": variables !== void 0 && capture.captureInput ? prepareCaptureValue("td.prompt.variables", variables, capture) : void 0
658
+ });
659
+ }
660
+ function checkOptions(name, options, type) {
661
+ if (typeof name !== "string" || name.length === 0) throw new TypeError("name must be a non-empty string");
662
+ if (type !== "chat" && type !== "text") throw new TypeError("type must be \"chat\" or \"text\"");
663
+ if (options.label !== void 0 && options.version !== void 0) throw new TypeError("Pass label or version, not both");
664
+ if (options.version !== void 0 && (!Number.isSafeInteger(options.version) || options.version < 1)) throw new RangeError("version must be a positive integer");
665
+ const ttl = options.cacheTtlSeconds;
666
+ if (ttl !== void 0 && !(Number.isFinite(ttl) && ttl >= 0)) throw new RangeError("cacheTtlSeconds must be a non-negative number");
667
+ if (options.fallback !== void 0 && (type === "chat" ? !Array.isArray(options.fallback) : typeof options.fallback !== "string")) throw new TypeError(type === "chat" ? "fallback must be a list of messages" : "fallback must be a string");
668
+ }
669
+ /**
670
+ * Returns the kept copy of a prompt, and fetches a new copy in the background when it is older
671
+ * than the TTL. Only a call with no copy waits for a fetch. A failed fetch with no copy fails the
672
+ * calls of the next ERROR_TTL_MS (or the TTL, if it is shorter) without a fetch.
673
+ */
674
+ async function load(key, ttlMs, fetchResponse, onError) {
675
+ let entry = cache.get(key);
676
+ if (!entry) {
677
+ if (cache.size >= MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
678
+ entry = { checkedAt: 0 };
679
+ cache.set(key, entry);
680
+ }
681
+ const fresh = Date.now() < entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS));
682
+ if (entry.response) {
683
+ if (!fresh) entry.pending ??= refresh(entry, fetchResponse, onError);
684
+ return entry.response;
685
+ }
686
+ if (fresh) throw entry.error;
687
+ return entry.pending ??= refresh(entry, fetchResponse, onError);
688
+ }
689
+ function refresh(entry, fetchResponse, onError) {
690
+ return fetchResponse().then((response) => {
691
+ entry.response = response;
692
+ return response;
693
+ }, (error) => {
694
+ if (!entry.response) {
695
+ entry.error = error;
696
+ throw error;
697
+ }
698
+ reportError(onError, error);
699
+ return entry.response;
700
+ }).finally(() => {
701
+ entry.checkedAt = Date.now();
702
+ entry.pending = void 0;
703
+ });
704
+ }
705
+ async function fetchPrompt(url, headers, fetchImpl) {
706
+ const controller = new AbortController();
707
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
708
+ try {
709
+ const res = await fetchImpl(url, {
710
+ method: "GET",
711
+ headers,
712
+ signal: controller.signal,
713
+ redirect: "manual"
714
+ });
715
+ const body = await res.json().catch(() => void 0);
716
+ if (!res.ok) {
717
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
718
+ throw new PromptError(typeof error.code === "string" ? error.code : `http_${res.status}`, typeof error.message === "string" ? error.message : `telemetry.dev returned ${res.status}`, res.status);
719
+ }
720
+ const response = parseResponse(body);
721
+ if (!response) throw new PromptError("invalid_response", "telemetry.dev returned a prompt it cannot read");
722
+ return response;
723
+ } catch (error) {
724
+ if (controller.signal.aborted) throw new PromptError("timeout", `Fetching the prompt took more than ${FETCH_TIMEOUT_MS} ms`);
725
+ if (error instanceof PromptError) throw error;
726
+ throw new PromptError("network_error", "Could not reach telemetry.dev", void 0, { cause: error });
727
+ } finally {
728
+ clearTimeout(timer);
729
+ }
730
+ }
731
+ function createPrompt(input) {
732
+ const { content, type } = input;
733
+ const prompt = Object.freeze({
734
+ name: input.name,
735
+ type,
736
+ version: input.version,
737
+ label: input.label,
738
+ messages: content.messages,
739
+ text: content.text,
740
+ model: content.model,
741
+ parameters: content.parameters,
742
+ tools: content.tools,
743
+ responseFormat: content.responseFormat,
744
+ isFallback: input.isFallback,
745
+ compile(variables = {}) {
746
+ compiledVariables.set(prompt, variables);
747
+ const scope = activeContext$1().getValue(PROMPT_KEY);
748
+ if (scope?.prompt === prompt) scope.variables = variables;
749
+ return type === "chat" ? compileMessages(content.messages, variables) : fill(content.text ?? "", variables);
750
+ }
751
+ });
752
+ return prompt;
753
+ }
754
+ function compileMessages(messages, variables) {
755
+ return messages.flatMap((message) => {
756
+ if (message.role !== "placeholder") return [{
757
+ role: message.role,
758
+ content: fill(message.content, variables)
759
+ }];
760
+ const value = Object.hasOwn(variables, message.name) ? variables[message.name] : void 0;
761
+ if (value == null) return [];
762
+ if (!Array.isArray(value)) throw new TypeError(`The ${message.name} variable must be a list of messages`);
763
+ return value;
764
+ });
765
+ }
766
+ function fill(text, variables) {
767
+ return text.replace(VARIABLE, (_tag, name) => {
768
+ const value = Object.hasOwn(variables, name) ? variables[name] : void 0;
769
+ if (value == null) return "";
770
+ return typeof value === "string" ? value : jsonAttr(value) ?? "";
771
+ });
772
+ }
773
+ function inRollout(response) {
774
+ const sessionId = sessionIdOf(activeContext$1()) ?? currentClient().core?.config.processSessionId;
775
+ return (sessionId === void 0 ? Math.floor(Math.random() * 100) : fnv1a(`${response.name}:${response.label}:${sessionId}`) % 100) < response.rollout.percent;
776
+ }
777
+ function fnv1a(text) {
778
+ let hash = 2166136261;
779
+ for (const byte of new TextEncoder().encode(text)) hash = Math.imul(hash ^ byte, 16777619) >>> 0;
780
+ return hash;
781
+ }
782
+ function keyFingerprint(apiKey) {
783
+ const digest = sha256(new TextEncoder().encode(apiKey)).subarray(0, 16);
784
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
785
+ }
786
+ function parseResponse(body) {
787
+ if (!isRecord(body)) return void 0;
788
+ const { name, type, label, version, rollout } = body;
789
+ if (typeof name !== "string" || type !== "chat" && type !== "text" || label !== null && typeof label !== "string" || !isVersion(version)) return;
790
+ const content = parseContent(body, type);
791
+ if (!content) return void 0;
792
+ if (rollout == null) return deepFreeze({
793
+ name,
794
+ type,
795
+ label,
796
+ version,
797
+ ...content,
798
+ rollout: null
799
+ });
800
+ if (!isRecord(rollout) || !isVersion(rollout.version) || !Number.isInteger(rollout.percent) || rollout.percent < 0 || rollout.percent > 100) return;
801
+ const rolloutContent = parseContent(rollout, type);
802
+ if (!rolloutContent) return void 0;
803
+ return deepFreeze({
804
+ name,
805
+ type,
806
+ label,
807
+ version,
808
+ ...content,
809
+ rollout: {
810
+ percent: rollout.percent,
811
+ version: rollout.version,
812
+ ...rolloutContent
813
+ }
814
+ });
815
+ }
816
+ function parseContent(value, type) {
817
+ const { messages, text, model, parameters, tools, responseFormat } = value;
818
+ if (!(type === "chat" ? Array.isArray(messages) && messages.every(isPromptMessage) : typeof text === "string") || !isRecord(model) || typeof model.provider !== "string" || typeof model.name !== "string" || !isRecord(parameters) || !Array.isArray(tools) || !tools.every((tool) => isRecord(tool) && typeof tool.name === "string") || !isRecord(responseFormat) || typeof responseFormat.type !== "string") return;
819
+ return {
820
+ messages: type === "chat" ? messages : [],
821
+ text: type === "text" ? text : null,
822
+ model,
823
+ parameters,
824
+ tools,
825
+ responseFormat
826
+ };
827
+ }
828
+ function isPromptMessage(value) {
829
+ if (!isRecord(value)) return false;
830
+ return value.role === "placeholder" ? typeof value.name === "string" : (value.role === "system" || value.role === "user" || value.role === "assistant") && typeof value.content === "string";
831
+ }
832
+ function isVersion(value) {
833
+ return Number.isSafeInteger(value) && value >= 1;
834
+ }
835
+ function isRecord(value) {
836
+ return typeof value === "object" && value !== null && !Array.isArray(value);
837
+ }
838
+ function deepFreeze(value) {
839
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
840
+ Object.freeze(value);
841
+ for (const item of Object.values(value)) deepFreeze(item);
842
+ }
843
+ return value;
844
+ }
845
+ //#endregion
549
846
  //#region src/span.ts
550
847
  const SPAN_META = /* @__PURE__ */ new WeakMap();
551
848
  const SEVERITY_ERROR = 17;
@@ -621,6 +918,7 @@ function createSpanHandle(core, name, options = {}) {
621
918
  "gen_ai.conversation.id": sessionId
622
919
  }), sessionId, core.config.apiKey);
623
920
  const attrs = fieldsToAttributes(options, meta.type, meta.capture);
921
+ const operation = attrs["gen_ai.operation.name"] ?? SPAN_TYPE_OPERATIONS[meta.type];
624
922
  const span = core.tracer.startSpan(name, {
625
923
  kind: options.kind ?? SpanKind.INTERNAL,
626
924
  startTime: options.startTime,
@@ -628,6 +926,7 @@ function createSpanHandle(core, name, options = {}) {
628
926
  attributes: {
629
927
  "gen_ai.operation.name": SPAN_TYPE_OPERATIONS[meta.type],
630
928
  ...propagatedFromContext(parentCtx),
929
+ ...promptAttributes(parentCtx, operation, meta.capture),
631
930
  ...attrs
632
931
  }
633
932
  }, parentCtx);
@@ -815,4 +1114,4 @@ function observe(fn, options) {
815
1114
  return wrapped;
816
1115
  }
817
1116
  //#endregion
818
- export { SCOPE_NAME, TelemetrySpanProcessor, activeContext, boundedCapture, boundedCaptureDetails, captureEnabled, captureMasked, extractW3cContext, flush, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext };
1117
+ export { PromptError, SCOPE_NAME, TelemetrySpanProcessor, activeContext, boundedCapture, boundedCaptureDetails, captureEnabled, captureMasked, extractW3cContext, flush, getPrompt, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext, withPrompt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/sdk",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Pure TypeScript SDK for telemetry.dev: LLM traces, logs, and GenAI metrics on OpenTelemetry, for Node and edge runtimes.",
5
5
  "keywords": [
6
6
  "genai",
package/src/config.ts CHANGED
@@ -22,7 +22,7 @@ export type MaskFn<T = unknown> = (value: T, ctx: { key: string }) => T;
22
22
  export interface TelemetryOptions {
23
23
  /** telemetry.dev ingest key (`td_live_…`). Falls back to `TELEMETRY_DEV_API_KEY`. When absent the SDK is a no-op. */
24
24
  apiKey?: string;
25
- /** Ingest base URL. Falls back to `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
25
+ /** Base URL for telemetry export and getPrompt. Falls back to `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
26
26
  baseUrl?: string;
27
27
  /** Deployment environment label. Falls back to `TELEMETRY_DEV_ENVIRONMENT`, then `production`. */
28
28
  environment?: string;
package/src/index.ts CHANGED
@@ -46,6 +46,25 @@ export { log, type LogOptions } from "./logs.ts";
46
46
 
47
47
  export { observe, type ObserveOptions } from "./observe.ts";
48
48
 
49
+ export {
50
+ type ChatMessage,
51
+ type ChatPrompt,
52
+ type GetChatPromptOptions,
53
+ getPrompt,
54
+ type GetPromptOptions,
55
+ type GetTextPromptOptions,
56
+ type Prompt,
57
+ PromptError,
58
+ type PromptMessage,
59
+ type PromptModel,
60
+ type PromptParameters,
61
+ type PromptResponseFormat,
62
+ type PromptTool,
63
+ type PromptVariables,
64
+ type TextPrompt,
65
+ withPrompt,
66
+ } from "./prompts.ts";
67
+
49
68
  export {
50
69
  activeContext,
51
70
  extractW3cContext,
package/src/prompts.ts ADDED
@@ -0,0 +1,701 @@
1
+ import { type Attributes, type Context, createContextKey } from "@opentelemetry/api";
2
+ import {
3
+ activeContext,
4
+ DEFAULT_BASE_URL,
5
+ jsonAttr,
6
+ omitUndefined,
7
+ reportError,
8
+ resolveEnv,
9
+ sessionIdOf,
10
+ sha256,
11
+ withContext,
12
+ } from "@telemetry-dev/otel";
13
+
14
+ import type { FieldCaptureConfig } from "./attrs.ts";
15
+ import { prepareCaptureValue } from "./capture.ts";
16
+ import { currentClient } from "./client.ts";
17
+
18
+ /** A message of a chat prompt. A placeholder stands for a list of messages that the app passes. */
19
+ export type PromptMessage =
20
+ | { readonly role: "system" | "user" | "assistant"; readonly content: string }
21
+ | { readonly role: "placeholder"; readonly name: string };
22
+
23
+ /** A message that `compile()` makes from a prompt message. */
24
+ export interface ChatMessage {
25
+ role: "system" | "user" | "assistant";
26
+ content: string;
27
+ }
28
+
29
+ export interface PromptModel {
30
+ readonly provider: string;
31
+ readonly name: string;
32
+ }
33
+
34
+ /** The parameters that are set in the version. A parameter that is not set is absent. */
35
+ export interface PromptParameters {
36
+ readonly temperature?: number;
37
+ readonly topP?: number;
38
+ readonly maxTokens?: number;
39
+ readonly frequencyPenalty?: number;
40
+ readonly presencePenalty?: number;
41
+ readonly stop?: readonly string[];
42
+ readonly seed?: number;
43
+ readonly toolChoice?: "auto" | "none" | "required" | { readonly tool: string };
44
+ readonly parallelToolCalls?: boolean;
45
+ }
46
+
47
+ export interface PromptTool {
48
+ readonly name: string;
49
+ readonly description: string;
50
+ /** JSON schema of the tool arguments. */
51
+ readonly parameters: Readonly<Record<string, unknown>>;
52
+ readonly strict: boolean;
53
+ }
54
+
55
+ export type PromptResponseFormat =
56
+ | { readonly type: "text" }
57
+ | { readonly type: "json_object" }
58
+ | {
59
+ readonly type: "json_schema";
60
+ readonly name: string;
61
+ readonly schema: Readonly<Record<string, unknown>>;
62
+ readonly strict: boolean;
63
+ };
64
+
65
+ /**
66
+ * Values for `{{name}}` variables. A missing value, null, or undefined becomes an empty text, and a
67
+ * value that is not a string becomes JSON. A placeholder message takes a list of messages.
68
+ */
69
+ export type PromptVariables<M = ChatMessage> = Readonly<
70
+ Record<string, string | number | boolean | null | undefined | readonly M[]>
71
+ >;
72
+
73
+ interface PromptBase {
74
+ readonly name: string;
75
+ /** The version that the app got; null for the fallback. */
76
+ readonly version: number | null;
77
+ /** The label that the prompt was fetched by; null when it was fetched by version. */
78
+ readonly label: string | null;
79
+ /** Null for the fallback. */
80
+ readonly model: PromptModel | null;
81
+ readonly parameters: PromptParameters;
82
+ readonly tools: readonly PromptTool[];
83
+ readonly responseFormat: PromptResponseFormat;
84
+ /** True when getPrompt could not fetch the prompt and returned the `fallback` option. */
85
+ readonly isFallback: boolean;
86
+ }
87
+
88
+ export interface ChatPrompt extends PromptBase {
89
+ readonly type: "chat";
90
+ readonly messages: readonly PromptMessage[];
91
+ readonly text: null;
92
+ /**
93
+ * Fills in the variables and replaces each placeholder message with the messages in its
94
+ * variable. `withPrompt` records the variables of the last call before it starts or inside it.
95
+ */
96
+ compile<M = ChatMessage>(variables?: PromptVariables<M>): (ChatMessage | M)[];
97
+ }
98
+
99
+ export interface TextPrompt extends PromptBase {
100
+ readonly type: "text";
101
+ readonly messages: readonly [];
102
+ readonly text: string;
103
+ /**
104
+ * Fills in the variables. `withPrompt` records the variables of the last call before it starts
105
+ * or inside it.
106
+ */
107
+ compile(variables?: PromptVariables): string;
108
+ }
109
+
110
+ export type Prompt = ChatPrompt | TextPrompt;
111
+
112
+ interface GetPromptOptionsBase {
113
+ /** Fetch the version that this label points to. Default "production" when `version` is not set. */
114
+ label?: string;
115
+ /** Fetch this version number. Do not set it together with `label`. */
116
+ version?: number;
117
+ /** Seconds before a call fetches a kept prompt again, in the background. Default 60. */
118
+ cacheTtlSeconds?: number;
119
+ /** API key with the prompts:read scope. Falls back to the initialized client, then `TELEMETRY_DEV_API_KEY`. */
120
+ apiKey?: string;
121
+ /** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
122
+ baseUrl?: string;
123
+ /** Falls back to the initialized client's `fetch`, then `globalThis.fetch`. */
124
+ fetch?: typeof fetch;
125
+ }
126
+
127
+ export interface GetChatPromptOptions extends GetPromptOptionsBase {
128
+ /** Default "chat". getPrompt rejects a prompt of the other type. */
129
+ type?: "chat";
130
+ /** Messages to use when the prompt cannot be fetched and no copy of it is kept. */
131
+ fallback?: readonly PromptMessage[];
132
+ }
133
+
134
+ export interface GetTextPromptOptions extends GetPromptOptionsBase {
135
+ type: "text";
136
+ /** Text to use when the prompt cannot be fetched and no copy of it is kept. */
137
+ fallback?: string;
138
+ }
139
+
140
+ export type GetPromptOptions = GetChatPromptOptions | GetTextPromptOptions;
141
+
142
+ /**
143
+ * Why getPrompt failed. `code` is the code of the API error (`prompt_not_found`,
144
+ * `label_not_found`, `insufficient_scope`, `invalid_api_key`, …), or `missing_api_key`,
145
+ * `timeout`, `network_error`, `invalid_response`, or `type_mismatch`. A response with a different
146
+ * status and no API error gives `http_<status>`, for example `http_302` for a redirect: getPrompt
147
+ * does not go to the location of a redirect.
148
+ */
149
+ export class PromptError extends Error {
150
+ override readonly name = "PromptError";
151
+
152
+ constructor(
153
+ readonly code: string,
154
+ message: string,
155
+ readonly status?: number,
156
+ options?: { cause?: unknown },
157
+ ) {
158
+ super(message, options);
159
+ }
160
+ }
161
+
162
+ interface PromptContent {
163
+ readonly messages: readonly PromptMessage[];
164
+ readonly text: string | null;
165
+ readonly model: PromptModel;
166
+ readonly parameters: PromptParameters;
167
+ readonly tools: readonly PromptTool[];
168
+ readonly responseFormat: PromptResponseFormat;
169
+ }
170
+
171
+ interface PromptResponse extends PromptContent {
172
+ readonly name: string;
173
+ readonly type: "chat" | "text";
174
+ readonly label: string | null;
175
+ readonly version: number;
176
+ readonly rollout: (PromptContent & { readonly percent: number; readonly version: number }) | null;
177
+ }
178
+
179
+ interface CacheEntry {
180
+ response?: PromptResponse;
181
+ /** Why the last fetch failed, when there was no response to keep. */
182
+ error?: unknown;
183
+ /** When the last fetch ended. */
184
+ checkedAt: number;
185
+ pending?: Promise<PromptResponse>;
186
+ }
187
+
188
+ /** The prompt of a `withPrompt` call and the variables that its spans record. */
189
+ interface PromptScope {
190
+ readonly prompt: Prompt;
191
+ variables: object | undefined;
192
+ }
193
+
194
+ const FETCH_TIMEOUT_MS = 10_000;
195
+ /** How long a failed fetch with no copy answers calls, so that they do not each fetch again. */
196
+ const ERROR_TTL_MS = 10_000;
197
+ const MAX_CACHE_ENTRIES = 1_000;
198
+ const VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
199
+ const MODEL_CALL_OPERATIONS = new Set<unknown>(["chat", "text_completion", "generate_content"]);
200
+ const PROMPT_KEY = createContextKey("telemetry.dev prompt");
201
+
202
+ const cache = new Map<string, CacheEntry>();
203
+ const compiledVariables = new WeakMap<Prompt, object>();
204
+
205
+ /**
206
+ * Fetches a prompt version from telemetry.dev and keeps it. When the copy is older than
207
+ * `cacheTtlSeconds`, a call returns it at once and fetches a new one in the background. Only a call
208
+ * with no copy waits for a fetch, at most 10 seconds. Calls for the same prompt share one fetch.
209
+ * When a fetch fails, getPrompt returns the last copy that it kept; with no copy it returns
210
+ * `fallback`. With neither it rejects with a `PromptError`: unlike the telemetry functions,
211
+ * getPrompt cannot fail open, because the app cannot run without the prompt. With no copy, the
212
+ * calls of the next 10 seconds (or the TTL, if it is shorter) fail the same way without a fetch.
213
+ *
214
+ * A label with a gradual rollout gives each session one version: the choice uses the session id
215
+ * of `propagateAttributes`, or a random choice per call without one.
216
+ */
217
+ export function getPrompt(name: string, options: GetTextPromptOptions): Promise<TextPrompt>;
218
+ export function getPrompt(name: string, options?: GetChatPromptOptions): Promise<ChatPrompt>;
219
+ export async function getPrompt(name: string, options: GetPromptOptions = {}): Promise<Prompt> {
220
+ const type = options.type ?? "chat";
221
+ checkOptions(name, options, type);
222
+
223
+ const core = currentClient().core;
224
+ const env = resolveEnv();
225
+ const apiKey = options.apiKey ?? core?.config.apiKey ?? env.TELEMETRY_DEV_API_KEY;
226
+
227
+ // An empty value counts as unset, as in the Python SDK.
228
+ const baseUrl = (
229
+ options.baseUrl ||
230
+ core?.config.baseUrl ||
231
+ env.TELEMETRY_DEV_BASE_URL ||
232
+ DEFAULT_BASE_URL
233
+ ).replace(/\/+$/, "");
234
+
235
+ const label = options.version === undefined ? (options.label ?? "production") : null;
236
+ const onError = core?.config.onError;
237
+ let response: PromptResponse;
238
+
239
+ try {
240
+ if (!apiKey) {
241
+ throw new PromptError(
242
+ "missing_api_key",
243
+ "No API key: pass apiKey or set TELEMETRY_DEV_API_KEY",
244
+ );
245
+ }
246
+
247
+ const url = new URL(`${baseUrl}/v1/prompts`);
248
+ url.searchParams.set("name", name);
249
+
250
+ if (label === null) url.searchParams.set("version", String(options.version));
251
+ else url.searchParams.set("label", label);
252
+
253
+ response = await load(
254
+ [
255
+ baseUrl,
256
+ keyFingerprint(apiKey),
257
+ name,
258
+ label === null ? `version:${options.version}` : `label:${label}`,
259
+ ].join("\0"),
260
+ (options.cacheTtlSeconds ?? 60) * 1000,
261
+ () =>
262
+ fetchPrompt(
263
+ url,
264
+ {
265
+ authorization: `Bearer ${apiKey}`,
266
+ "x-telemetry-dev-sdk": core?.config.sdkName ?? "@telemetry-dev/sdk",
267
+ },
268
+ options.fetch ?? core?.config.fetchImpl ?? globalThis.fetch,
269
+ ),
270
+ onError,
271
+ );
272
+ } catch (error) {
273
+ if (options.fallback === undefined) throw error;
274
+ reportError(onError, error);
275
+
276
+ return createPrompt({
277
+ name,
278
+ type,
279
+ label,
280
+ version: null,
281
+ isFallback: true,
282
+ content: {
283
+ messages: type === "chat" ? (options.fallback as readonly PromptMessage[]) : [],
284
+ text: type === "text" ? (options.fallback as string) : null,
285
+ model: null,
286
+ parameters: {},
287
+ tools: [],
288
+ responseFormat: { type: "text" },
289
+ },
290
+ });
291
+ }
292
+
293
+ if (response.type !== type) {
294
+ throw new PromptError(
295
+ "type_mismatch",
296
+ `${name} is a ${response.type} prompt; pass type: "${response.type}"`,
297
+ );
298
+ }
299
+
300
+ const chosen = response.rollout && inRollout(response) ? response.rollout : response;
301
+
302
+ return createPrompt({
303
+ name: response.name,
304
+ type,
305
+ label: response.label,
306
+ version: chosen.version,
307
+ isFallback: false,
308
+ content: chosen,
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Runs `fn` with `prompt` as the prompt of each model call (generation span) that starts inside
314
+ * it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
315
+ * capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
316
+ * prompt object before withPrompt starts, or of the last `compile()` inside `fn`, so a compile in
317
+ * another task does not change them once withPrompt runs.
318
+ */
319
+ export function withPrompt<T>(prompt: Prompt, fn: () => T): T {
320
+ const scope: PromptScope = { prompt, variables: compiledVariables.get(prompt) };
321
+
322
+ return withContext(activeContext().setValue(PROMPT_KEY, scope), fn);
323
+ }
324
+
325
+ /** The td.prompt.* attributes for a span with this operation that starts in `ctx`. */
326
+ export function promptAttributes(
327
+ ctx: Context,
328
+ operation: unknown,
329
+ capture: FieldCaptureConfig,
330
+ ): Attributes | undefined {
331
+ if (!MODEL_CALL_OPERATIONS.has(operation)) return undefined;
332
+
333
+ const scope = (ctx.getValue(PROMPT_KEY) ?? activeContext().getValue(PROMPT_KEY)) as
334
+ | PromptScope
335
+ | undefined;
336
+
337
+ if (!scope) return undefined;
338
+ const { prompt, variables } = scope;
339
+
340
+ return omitUndefined({
341
+ "td.prompt.name": prompt.name,
342
+ "td.prompt.version": prompt.version ?? undefined,
343
+ "td.prompt.label": prompt.label ?? undefined,
344
+ "td.prompt.variables":
345
+ variables !== undefined && capture.captureInput
346
+ ? prepareCaptureValue("td.prompt.variables", variables, capture)
347
+ : undefined,
348
+ });
349
+ }
350
+
351
+ function checkOptions(name: string, options: GetPromptOptions, type: string): void {
352
+ if (typeof name !== "string" || name.length === 0) {
353
+ throw new TypeError("name must be a non-empty string");
354
+ }
355
+
356
+ if (type !== "chat" && type !== "text") throw new TypeError('type must be "chat" or "text"');
357
+
358
+ if (options.label !== undefined && options.version !== undefined) {
359
+ throw new TypeError("Pass label or version, not both");
360
+ }
361
+
362
+ if (
363
+ options.version !== undefined &&
364
+ (!Number.isSafeInteger(options.version) || options.version < 1)
365
+ ) {
366
+ throw new RangeError("version must be a positive integer");
367
+ }
368
+
369
+ const ttl = options.cacheTtlSeconds;
370
+
371
+ if (ttl !== undefined && !(Number.isFinite(ttl) && ttl >= 0)) {
372
+ throw new RangeError("cacheTtlSeconds must be a non-negative number");
373
+ }
374
+
375
+ if (
376
+ options.fallback !== undefined &&
377
+ (type === "chat" ? !Array.isArray(options.fallback) : typeof options.fallback !== "string")
378
+ ) {
379
+ throw new TypeError(
380
+ type === "chat" ? "fallback must be a list of messages" : "fallback must be a string",
381
+ );
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Returns the kept copy of a prompt, and fetches a new copy in the background when it is older
387
+ * than the TTL. Only a call with no copy waits for a fetch. A failed fetch with no copy fails the
388
+ * calls of the next ERROR_TTL_MS (or the TTL, if it is shorter) without a fetch.
389
+ */
390
+ async function load(
391
+ key: string,
392
+ ttlMs: number,
393
+ fetchResponse: () => Promise<PromptResponse>,
394
+ onError: ((cause: Error) => void) | undefined,
395
+ ): Promise<PromptResponse> {
396
+ let entry = cache.get(key);
397
+
398
+ if (!entry) {
399
+ if (cache.size >= MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
400
+ entry = { checkedAt: 0 };
401
+ cache.set(key, entry);
402
+ }
403
+
404
+ const fresh =
405
+ Date.now() < entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS));
406
+
407
+ if (entry.response) {
408
+ if (!fresh) entry.pending ??= refresh(entry, fetchResponse, onError);
409
+
410
+ return entry.response;
411
+ }
412
+
413
+ if (fresh) throw entry.error;
414
+
415
+ return (entry.pending ??= refresh(entry, fetchResponse, onError));
416
+ }
417
+
418
+ function refresh(
419
+ entry: CacheEntry,
420
+ fetchResponse: () => Promise<PromptResponse>,
421
+ onError: ((cause: Error) => void) | undefined,
422
+ ): Promise<PromptResponse> {
423
+ return fetchResponse()
424
+ .then(
425
+ (response) => {
426
+ entry.response = response;
427
+
428
+ return response;
429
+ },
430
+ (error: unknown) => {
431
+ if (!entry.response) {
432
+ entry.error = error;
433
+ throw error;
434
+ }
435
+
436
+ reportError(onError, error);
437
+
438
+ return entry.response;
439
+ },
440
+ )
441
+ .finally(() => {
442
+ // A failed fetch counts too, so that calls do not each start a fetch while it fails.
443
+ entry.checkedAt = Date.now();
444
+ entry.pending = undefined;
445
+ });
446
+ }
447
+
448
+ async function fetchPrompt(
449
+ url: URL,
450
+ headers: Record<string, string>,
451
+ fetchImpl: typeof fetch,
452
+ ): Promise<PromptResponse> {
453
+ const controller = new AbortController();
454
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
455
+
456
+ try {
457
+ const res = await fetchImpl(url, {
458
+ method: "GET",
459
+ headers,
460
+ signal: controller.signal,
461
+ // A redirect to another host must not get the API key, so a 3xx fails below like any status
462
+ // that is not OK. Cloudflare Workers reject redirect: "error".
463
+ redirect: "manual",
464
+ });
465
+
466
+ const body: unknown = await res.json().catch(() => undefined);
467
+
468
+ if (!res.ok) {
469
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
470
+
471
+ throw new PromptError(
472
+ typeof error.code === "string" ? error.code : `http_${res.status}`,
473
+ typeof error.message === "string" ? error.message : `telemetry.dev returned ${res.status}`,
474
+ res.status,
475
+ );
476
+ }
477
+
478
+ const response = parseResponse(body);
479
+
480
+ if (!response) {
481
+ throw new PromptError("invalid_response", "telemetry.dev returned a prompt it cannot read");
482
+ }
483
+
484
+ return response;
485
+ } catch (error) {
486
+ // The timeout can also cut the body, which then reads as an invalid response.
487
+ if (controller.signal.aborted) {
488
+ throw new PromptError("timeout", `Fetching the prompt took more than ${FETCH_TIMEOUT_MS} ms`);
489
+ }
490
+
491
+ if (error instanceof PromptError) throw error;
492
+
493
+ throw new PromptError("network_error", "Could not reach telemetry.dev", undefined, {
494
+ cause: error,
495
+ });
496
+ } finally {
497
+ clearTimeout(timer);
498
+ }
499
+ }
500
+
501
+ function createPrompt(input: {
502
+ name: string;
503
+ type: "chat" | "text";
504
+ label: string | null;
505
+ version: number | null;
506
+ isFallback: boolean;
507
+ content: Omit<PromptContent, "model"> & { readonly model: PromptModel | null };
508
+ }): Prompt {
509
+ const { content, type } = input;
510
+
511
+ const prompt: Prompt = Object.freeze({
512
+ name: input.name,
513
+ type,
514
+ version: input.version,
515
+ label: input.label,
516
+ messages: content.messages,
517
+ text: content.text,
518
+ model: content.model,
519
+ parameters: content.parameters,
520
+ tools: content.tools,
521
+ responseFormat: content.responseFormat,
522
+ isFallback: input.isFallback,
523
+ compile(variables: PromptVariables<unknown> = {}) {
524
+ compiledVariables.set(prompt, variables);
525
+ const scope = activeContext().getValue(PROMPT_KEY) as PromptScope | undefined;
526
+
527
+ if (scope?.prompt === prompt) scope.variables = variables;
528
+
529
+ return type === "chat"
530
+ ? compileMessages(content.messages, variables)
531
+ : fill(content.text ?? "", variables);
532
+ },
533
+ }) as Prompt;
534
+
535
+ return prompt;
536
+ }
537
+
538
+ function compileMessages(
539
+ messages: readonly PromptMessage[],
540
+ variables: PromptVariables<unknown>,
541
+ ): unknown[] {
542
+ return messages.flatMap((message) => {
543
+ if (message.role !== "placeholder") {
544
+ return [{ role: message.role, content: fill(message.content, variables) }];
545
+ }
546
+
547
+ const value = Object.hasOwn(variables, message.name) ? variables[message.name] : undefined;
548
+
549
+ if (value == null) return [];
550
+
551
+ if (!Array.isArray(value)) {
552
+ throw new TypeError(`The ${message.name} variable must be a list of messages`);
553
+ }
554
+
555
+ return value;
556
+ });
557
+ }
558
+
559
+ function fill(text: string, variables: PromptVariables<unknown>): string {
560
+ return text.replace(VARIABLE, (_tag, name: string) => {
561
+ const value = Object.hasOwn(variables, name) ? variables[name] : undefined;
562
+
563
+ if (value == null) return "";
564
+
565
+ return typeof value === "string" ? value : (jsonAttr(value) ?? "");
566
+ });
567
+ }
568
+
569
+ // One session keeps one version: FNV-1a 32-bit of "<name>:<label>:<session id>", mod 100.
570
+ function inRollout(response: PromptResponse): boolean {
571
+ const sessionId = sessionIdOf(activeContext()) ?? currentClient().core?.config.processSessionId;
572
+
573
+ const bucket =
574
+ sessionId === undefined
575
+ ? Math.floor(Math.random() * 100)
576
+ : fnv1a(`${response.name}:${response.label}:${sessionId}`) % 100;
577
+
578
+ return bucket < response.rollout!.percent;
579
+ }
580
+
581
+ function fnv1a(text: string): number {
582
+ let hash = 2166136261;
583
+
584
+ for (const byte of new TextEncoder().encode(text)) {
585
+ hash = Math.imul(hash ^ byte, 16777619) >>> 0;
586
+ }
587
+
588
+ return hash;
589
+ }
590
+
591
+ function keyFingerprint(apiKey: string): string {
592
+ const digest = sha256(new TextEncoder().encode(apiKey)).subarray(0, 16);
593
+
594
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
595
+ }
596
+
597
+ function parseResponse(body: unknown): PromptResponse | undefined {
598
+ if (!isRecord(body)) return undefined;
599
+ const { name, type, label, version, rollout } = body;
600
+
601
+ if (
602
+ typeof name !== "string" ||
603
+ (type !== "chat" && type !== "text") ||
604
+ (label !== null && typeof label !== "string") ||
605
+ !isVersion(version)
606
+ ) {
607
+ return undefined;
608
+ }
609
+
610
+ const content = parseContent(body, type);
611
+
612
+ if (!content) return undefined;
613
+
614
+ if (rollout == null) return deepFreeze({ name, type, label, version, ...content, rollout: null });
615
+
616
+ if (
617
+ !isRecord(rollout) ||
618
+ !isVersion(rollout.version) ||
619
+ !Number.isInteger(rollout.percent) ||
620
+ (rollout.percent as number) < 0 ||
621
+ (rollout.percent as number) > 100
622
+ ) {
623
+ return undefined;
624
+ }
625
+
626
+ const rolloutContent = parseContent(rollout, type);
627
+
628
+ if (!rolloutContent) return undefined;
629
+
630
+ return deepFreeze({
631
+ name,
632
+ type,
633
+ label,
634
+ version,
635
+ ...content,
636
+ rollout: { percent: rollout.percent as number, version: rollout.version, ...rolloutContent },
637
+ });
638
+ }
639
+
640
+ function parseContent(
641
+ value: Record<string, unknown>,
642
+ type: "chat" | "text",
643
+ ): PromptContent | undefined {
644
+ const { messages, text, model, parameters, tools, responseFormat } = value;
645
+
646
+ const validBody =
647
+ type === "chat"
648
+ ? Array.isArray(messages) && messages.every(isPromptMessage)
649
+ : typeof text === "string";
650
+
651
+ if (
652
+ !validBody ||
653
+ !isRecord(model) ||
654
+ typeof model.provider !== "string" ||
655
+ typeof model.name !== "string" ||
656
+ !isRecord(parameters) ||
657
+ !Array.isArray(tools) ||
658
+ !tools.every((tool) => isRecord(tool) && typeof tool.name === "string") ||
659
+ !isRecord(responseFormat) ||
660
+ typeof responseFormat.type !== "string"
661
+ ) {
662
+ return undefined;
663
+ }
664
+
665
+ return {
666
+ messages: type === "chat" ? (messages as PromptMessage[]) : [],
667
+ text: type === "text" ? (text as string) : null,
668
+ model: model as unknown as PromptModel,
669
+ parameters: parameters as PromptParameters,
670
+ tools: tools as PromptTool[],
671
+ responseFormat: responseFormat as unknown as PromptResponseFormat,
672
+ };
673
+ }
674
+
675
+ function isPromptMessage(value: unknown): value is PromptMessage {
676
+ if (!isRecord(value)) return false;
677
+
678
+ return value.role === "placeholder"
679
+ ? typeof value.name === "string"
680
+ : (value.role === "system" || value.role === "user" || value.role === "assistant") &&
681
+ typeof value.content === "string";
682
+ }
683
+
684
+ function isVersion(value: unknown): value is number {
685
+ return Number.isSafeInteger(value) && (value as number) >= 1;
686
+ }
687
+
688
+ function isRecord(value: unknown): value is Record<string, unknown> {
689
+ return typeof value === "object" && value !== null && !Array.isArray(value);
690
+ }
691
+
692
+ // Prompt objects share the cached response, so an app cannot change the cache through them.
693
+ function deepFreeze<T>(value: T): T {
694
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
695
+ Object.freeze(value);
696
+
697
+ for (const item of Object.values(value)) deepFreeze(item);
698
+ }
699
+
700
+ return value;
701
+ }
package/src/span.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  import { prepareCaptureValue } from "./capture.ts";
42
42
  import { type ClientCore, currentClient } from "./client.ts";
43
43
  import { NOOP_SPAN_HANDLE } from "./noop.ts";
44
+ import { promptAttributes } from "./prompts.ts";
44
45
 
45
46
  export type ParentRef = string /* W3C traceparent */ | SpanHandle | SpanContext | Context;
46
47
 
@@ -229,6 +230,7 @@ export function createSpanHandle(
229
230
 
230
231
  const parentCtx = withSessionParent(sessionContext, sessionId, core.config.apiKey);
231
232
  const attrs = fieldsToAttributes(options, meta.type, meta.capture);
233
+ const operation = attrs["gen_ai.operation.name"] ?? SPAN_TYPE_OPERATIONS[meta.type];
232
234
 
233
235
  const span = core.tracer.startSpan(
234
236
  name,
@@ -239,6 +241,7 @@ export function createSpanHandle(
239
241
  attributes: {
240
242
  "gen_ai.operation.name": SPAN_TYPE_OPERATIONS[meta.type],
241
243
  ...propagatedFromContext(parentCtx),
244
+ ...promptAttributes(parentCtx, operation, meta.capture),
242
245
  ...attrs,
243
246
  },
244
247
  },