@telemetry-dev/sdk 0.1.11 → 0.2.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pure TypeScript SDK for [telemetry.dev](https://telemetry.dev): add LLM traces, logs, and GenAI
4
4
  metrics to any app. Built on OpenTelemetry — spans use the `gen_ai.*` semantic conventions and
5
- ship as OTLP protobuf to `https://ingest.telemetry.dev`. Works on Node >= 20.19 and edge runtimes
5
+ ship as OTLP protobuf to `https://api.telemetry.dev`. Works on Node >= 20.19 and edge runtimes
6
6
  (Vercel Edge, and Cloudflare Workers with Node.js compatibility, which is on by default for
7
7
  compatibility dates of 2026-08-04 or later; earlier dates need the `nodejs_compat` flag). ESM-only;
8
8
  CJS consumers can `require()` it on Node >= 20.19.
@@ -59,12 +59,16 @@ Without an API key every call is a silent no-op — the SDK never throws into yo
59
59
 
60
60
  ## Configuration
61
61
 
62
- | Option | Env var | Default |
63
- | ------------- | --------------------------- | ------------------------------ |
64
- | `apiKey` | `TELEMETRY_DEV_API_KEY` | — (absent ⇒ no-op) |
65
- | `baseUrl` | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` |
66
- | `environment` | `TELEMETRY_DEV_ENVIRONMENT` | `production` |
67
- | `serviceName` | `OTEL_SERVICE_NAME` | `unknown_service` |
62
+ | Option | Env var | Default |
63
+ | ------------- | --------------------------- | --------------------------- |
64
+ | `apiKey` | `TELEMETRY_DEV_API_KEY` | — (absent ⇒ no-op) |
65
+ | `baseUrl` | `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` |
66
+ | `environment` | `TELEMETRY_DEV_ENVIRONMENT` | `production` |
67
+ | `serviceName` | `OTEL_SERVICE_NAME` | `unknown_service` |
68
+
69
+ Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
70
+ same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
71
+ `baseUrl` to it.
68
72
 
69
73
  Other `init()` options: `enabled` (kill switch), `registerGlobal`, `exportMode: "batched" |
70
74
  "immediate"`, `captureInput` / `captureOutput`, `mask(value, { key })` redaction hook,
@@ -151,11 +155,13 @@ const pinned = await getPrompt("support/reply", { version: 13 });
151
155
  `timeout`. A response with a different status and no API error gives `http_<status>`, for
152
156
  example `http_302` for a redirect. getPrompt does not send the request to the location of a
153
157
  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.
158
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
159
+ response with a `Retry-After` header, getPrompt does not fetch that prompt again before that time
160
+ (one hour at most). This is the one SDK function that can reject: your app cannot run without
161
+ its prompt.
156
162
  - getPrompt uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY` and
157
163
  `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
164
+ host that telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read
159
165
  scope for prompts.
160
166
  - When a label sends part of the traffic to a second version, each session keeps one version: the
161
167
  choice uses the session id of `propagateAttributes` (or of `sessionMode: "process"`). Without a
@@ -167,12 +173,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
167
173
  `compile()` of that prompt object before `withPrompt` starts, or of the last `compile()` inside
168
174
  `fn`, after `mask` and truncation. If tasks that run at the same time share one prompt object, call
169
175
  `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
+ the cache. If you do not, a task can record the variables of a different task. Tasks that run at the
177
+ same time inside one `withPrompt` share its variables, so call `withPrompt` in each task that
178
+ compiles its own. Model calls are the spans of type `generation` (operations `chat`,
179
+ `text_completion`, and `generate_content`), including the spans of the provider integrations that
180
+ use this SDK: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, and OpenRouter. Spans from the AI SDK
181
+ and TanStack AI integrations, and from other OpenTelemetry tracers, do not get the attributes.
182
+ Messages from `compile()` that you pass to a model call outside `withPrompt` are not recorded.
176
183
 
177
184
  ## API
178
185
 
@@ -265,7 +272,7 @@ Alternatively `init({ registerGlobal: true })` makes the SDK's provider the app'
265
272
  that mode only this SDK's spans are exported by default.
266
273
 
267
274
  Any vanilla OTel app can also skip the SDK entirely: point a stock OTLP/HTTP protobuf exporter at
268
- `https://ingest.telemetry.dev/v1/traces` with an `Authorization: Bearer td_live_...` header.
275
+ `https://api.telemetry.dev/v1/traces` with an `Authorization: Bearer td_live_...` header.
269
276
 
270
277
  ### Native Claude SDK spans
271
278
 
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
- /** Base URL for telemetry export and getPrompt. 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://api.telemetry.dev`. */
20
20
  baseUrl?: string;
21
21
  /** Deployment environment label. Falls back to `TELEMETRY_DEV_ENVIRONMENT`, then `production`. */
22
22
  environment?: string;
@@ -355,7 +355,7 @@ interface GetPromptOptionsBase {
355
355
  cacheTtlSeconds?: number;
356
356
  /** API key with the prompts:read scope. Falls back to the initialized client, then `TELEMETRY_DEV_API_KEY`. */
357
357
  apiKey?: string;
358
- /** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
358
+ /** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://api.telemetry.dev`. */
359
359
  baseUrl?: string;
360
360
  /** Falls back to the initialized client's `fetch`, then `globalThis.fetch`. */
361
361
  fetch?: typeof fetch;
@@ -406,7 +406,8 @@ declare function getPrompt(name: string, options?: GetChatPromptOptions): Promis
406
406
  * it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
407
407
  * capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
408
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.
409
+ * another task does not change them once withPrompt runs. Concurrent tasks in `fn` share the
410
+ * variables, so call withPrompt in each task that compiles its own.
410
411
  */
411
412
  declare function withPrompt<T>(prompt: Prompt, fn: () => T): T;
412
413
  //#endregion
package/dist/index.mjs CHANGED
@@ -563,9 +563,13 @@ var PromptError = class extends Error {
563
563
  }
564
564
  };
565
565
  const FETCH_TIMEOUT_MS = 1e4;
566
+ /** The longest Retry-After that getPrompt honors, so that a bad header cannot stop fetches. */
567
+ const MAX_RETRY_AFTER_MS = 36e5;
566
568
  /** How long a failed fetch with no copy answers calls, so that they do not each fetch again. */
567
569
  const ERROR_TTL_MS = 1e4;
568
570
  const MAX_CACHE_ENTRIES = 1e3;
571
+ /** IMF-fixdate, the HTTP date that servers send: `Sun, 06 Nov 1994 08:49:37 GMT`. */
572
+ const HTTP_DATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
569
573
  const VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
570
574
  const MODEL_CALL_OPERATIONS = new Set([
571
575
  "chat",
@@ -575,6 +579,8 @@ const MODEL_CALL_OPERATIONS = new Set([
575
579
  const PROMPT_KEY = createContextKey("telemetry.dev prompt");
576
580
  const cache = /* @__PURE__ */ new Map();
577
581
  const compiledVariables = /* @__PURE__ */ new WeakMap();
582
+ /** The Retry-After of the response that failed with this error, in milliseconds. */
583
+ const retryAfter = /* @__PURE__ */ new WeakMap();
578
584
  async function getPrompt(name, options = {}) {
579
585
  const type = options.type ?? "chat";
580
586
  checkOptions(name, options, type);
@@ -635,7 +641,8 @@ async function getPrompt(name, options = {}) {
635
641
  * it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
636
642
  * capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
637
643
  * 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.
644
+ * another task does not change them once withPrompt runs. Concurrent tasks in `fn` share the
645
+ * variables, so call withPrompt in each task that compiles its own.
639
646
  */
640
647
  function withPrompt(prompt, fn) {
641
648
  const scope = {
@@ -675,10 +682,13 @@ async function load(key, ttlMs, fetchResponse, onError) {
675
682
  let entry = cache.get(key);
676
683
  if (!entry) {
677
684
  if (cache.size >= MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
678
- entry = { checkedAt: 0 };
685
+ entry = {
686
+ checkedAt: 0,
687
+ retryAt: 0
688
+ };
679
689
  cache.set(key, entry);
680
690
  }
681
- const fresh = Date.now() < entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS));
691
+ const fresh = Date.now() < Math.max(entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS)), entry.retryAt);
682
692
  if (entry.response) {
683
693
  if (!fresh) entry.pending ??= refresh(entry, fetchResponse, onError);
684
694
  return entry.response;
@@ -689,8 +699,11 @@ async function load(key, ttlMs, fetchResponse, onError) {
689
699
  function refresh(entry, fetchResponse, onError) {
690
700
  return fetchResponse().then((response) => {
691
701
  entry.response = response;
702
+ entry.retryAt = 0;
692
703
  return response;
693
704
  }, (error) => {
705
+ const delay = error instanceof PromptError ? retryAfter.get(error) : void 0;
706
+ entry.retryAt = delay === void 0 ? 0 : Date.now() + delay;
694
707
  if (!entry.response) {
695
708
  entry.error = error;
696
709
  throw error;
@@ -715,7 +728,10 @@ async function fetchPrompt(url, headers, fetchImpl) {
715
728
  const body = await res.json().catch(() => void 0);
716
729
  if (!res.ok) {
717
730
  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);
731
+ const failure = new PromptError(typeof error.code === "string" ? error.code : `http_${res.status}`, typeof error.message === "string" ? error.message : `telemetry.dev returned ${res.status}`, res.status);
732
+ const delay = retryAfterMs(res);
733
+ if (delay !== void 0) retryAfter.set(failure, delay);
734
+ throw failure;
719
735
  }
720
736
  const response = parseResponse(body);
721
737
  if (!response) throw new PromptError("invalid_response", "telemetry.dev returned a prompt it cannot read");
@@ -783,6 +799,17 @@ function keyFingerprint(apiKey) {
783
799
  const digest = sha256(new TextEncoder().encode(apiKey)).subarray(0, 16);
784
800
  return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
785
801
  }
802
+ /** The Retry-After of a 429 or 503 response, as seconds or an IMF-fixdate HTTP date, capped. */
803
+ function retryAfterMs(res) {
804
+ if (res.status !== 429 && res.status !== 503) return void 0;
805
+ const value = res.headers.get("retry-after")?.trim();
806
+ if (!value) return void 0;
807
+ let ms;
808
+ if (/^\d+$/.test(value)) ms = Number(value) * 1e3;
809
+ else if (HTTP_DATE.test(value)) ms = Date.parse(value) - Date.now();
810
+ else return void 0;
811
+ return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : void 0;
812
+ }
786
813
  function parseResponse(body) {
787
814
  if (!isRecord(body)) return void 0;
788
815
  const { name, type, label, version, rollout } = body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/sdk",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
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",
@@ -42,7 +42,7 @@
42
42
  "@opentelemetry/sdk-logs": "^0.223.0",
43
43
  "@opentelemetry/sdk-metrics": "^2.12.0",
44
44
  "@opentelemetry/sdk-trace-base": "^2.12.0",
45
- "@telemetry-dev/otel": "^0.1.7"
45
+ "@telemetry-dev/otel": "^0.2.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@opentelemetry/api": "^1.9.1",
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
- /** Base URL for telemetry export and getPrompt. 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://api.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/prompts.ts CHANGED
@@ -118,7 +118,7 @@ interface GetPromptOptionsBase {
118
118
  cacheTtlSeconds?: number;
119
119
  /** API key with the prompts:read scope. Falls back to the initialized client, then `TELEMETRY_DEV_API_KEY`. */
120
120
  apiKey?: string;
121
- /** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
121
+ /** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://api.telemetry.dev`. */
122
122
  baseUrl?: string;
123
123
  /** Falls back to the initialized client's `fetch`, then `globalThis.fetch`. */
124
124
  fetch?: typeof fetch;
@@ -182,6 +182,8 @@ interface CacheEntry {
182
182
  error?: unknown;
183
183
  /** When the last fetch ended. */
184
184
  checkedAt: number;
185
+ /** No fetch before this time: the Retry-After of the last 429 or 503 response. */
186
+ retryAt: number;
185
187
  pending?: Promise<PromptResponse>;
186
188
  }
187
189
 
@@ -192,15 +194,21 @@ interface PromptScope {
192
194
  }
193
195
 
194
196
  const FETCH_TIMEOUT_MS = 10_000;
197
+ /** The longest Retry-After that getPrompt honors, so that a bad header cannot stop fetches. */
198
+ const MAX_RETRY_AFTER_MS = 3_600_000;
195
199
  /** How long a failed fetch with no copy answers calls, so that they do not each fetch again. */
196
200
  const ERROR_TTL_MS = 10_000;
197
201
  const MAX_CACHE_ENTRIES = 1_000;
202
+ /** IMF-fixdate, the HTTP date that servers send: `Sun, 06 Nov 1994 08:49:37 GMT`. */
203
+ const HTTP_DATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
198
204
  const VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
199
205
  const MODEL_CALL_OPERATIONS = new Set<unknown>(["chat", "text_completion", "generate_content"]);
200
206
  const PROMPT_KEY = createContextKey("telemetry.dev prompt");
201
207
 
202
208
  const cache = new Map<string, CacheEntry>();
203
209
  const compiledVariables = new WeakMap<Prompt, object>();
210
+ /** The Retry-After of the response that failed with this error, in milliseconds. */
211
+ const retryAfter = new WeakMap<PromptError, number>();
204
212
 
205
213
  /**
206
214
  * Fetches a prompt version from telemetry.dev and keeps it. When the copy is older than
@@ -314,7 +322,8 @@ export async function getPrompt(name: string, options: GetPromptOptions = {}): P
314
322
  * it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
315
323
  * capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
316
324
  * 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.
325
+ * another task does not change them once withPrompt runs. Concurrent tasks in `fn` share the
326
+ * variables, so call withPrompt in each task that compiles its own.
318
327
  */
319
328
  export function withPrompt<T>(prompt: Prompt, fn: () => T): T {
320
329
  const scope: PromptScope = { prompt, variables: compiledVariables.get(prompt) };
@@ -397,12 +406,16 @@ async function load(
397
406
 
398
407
  if (!entry) {
399
408
  if (cache.size >= MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
400
- entry = { checkedAt: 0 };
409
+ entry = { checkedAt: 0, retryAt: 0 };
401
410
  cache.set(key, entry);
402
411
  }
403
412
 
404
413
  const fresh =
405
- Date.now() < entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS));
414
+ Date.now() <
415
+ Math.max(
416
+ entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS)),
417
+ entry.retryAt,
418
+ );
406
419
 
407
420
  if (entry.response) {
408
421
  if (!fresh) entry.pending ??= refresh(entry, fetchResponse, onError);
@@ -424,10 +437,14 @@ function refresh(
424
437
  .then(
425
438
  (response) => {
426
439
  entry.response = response;
440
+ entry.retryAt = 0;
427
441
 
428
442
  return response;
429
443
  },
430
444
  (error: unknown) => {
445
+ const delay = error instanceof PromptError ? retryAfter.get(error) : undefined;
446
+ entry.retryAt = delay === undefined ? 0 : Date.now() + delay;
447
+
431
448
  if (!entry.response) {
432
449
  entry.error = error;
433
450
  throw error;
@@ -468,11 +485,16 @@ async function fetchPrompt(
468
485
  if (!res.ok) {
469
486
  const error = isRecord(body) && isRecord(body.error) ? body.error : {};
470
487
 
471
- throw new PromptError(
488
+ const failure = new PromptError(
472
489
  typeof error.code === "string" ? error.code : `http_${res.status}`,
473
490
  typeof error.message === "string" ? error.message : `telemetry.dev returned ${res.status}`,
474
491
  res.status,
475
492
  );
493
+
494
+ const delay = retryAfterMs(res);
495
+ if (delay !== undefined) retryAfter.set(failure, delay);
496
+
497
+ throw failure;
476
498
  }
477
499
 
478
500
  const response = parseResponse(body);
@@ -594,6 +616,23 @@ function keyFingerprint(apiKey: string): string {
594
616
  return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
595
617
  }
596
618
 
619
+ /** The Retry-After of a 429 or 503 response, as seconds or an IMF-fixdate HTTP date, capped. */
620
+ function retryAfterMs(res: Response): number | undefined {
621
+ if (res.status !== 429 && res.status !== 503) return undefined;
622
+
623
+ const value = res.headers.get("retry-after")?.trim();
624
+
625
+ if (!value) return undefined;
626
+
627
+ let ms: number;
628
+
629
+ if (/^\d+$/.test(value)) ms = Number(value) * 1000;
630
+ else if (HTTP_DATE.test(value)) ms = Date.parse(value) - Date.now();
631
+ else return undefined;
632
+
633
+ return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
634
+ }
635
+
597
636
  function parseResponse(body: unknown): PromptResponse | undefined {
598
637
  if (!isRecord(body)) return undefined;
599
638
  const { name, type, label, version, rollout } = body;