@telemetry-dev/sdk 0.1.11 → 0.1.12

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
@@ -151,8 +151,10 @@ const pinned = await getPrompt("support/reply", { version: 13 });
151
151
  `timeout`. A response with a different status and no API error gives `http_<status>`, for
152
152
  example `http_302` for a redirect. getPrompt does not send the request to the location of a
153
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.
154
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
155
+ response with a `Retry-After` header, getPrompt does not fetch that prompt again before that time
156
+ (one hour at most). This is the one SDK function that can reject: your app cannot run without
157
+ its prompt.
156
158
  - getPrompt uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY` and
157
159
  `TELEMETRY_DEV_BASE_URL`, or its own `apiKey`, `baseUrl`, and `fetch` options. It reads the same
158
160
  host that telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read
@@ -167,12 +169,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
167
169
  `compile()` of that prompt object before `withPrompt` starts, or of the last `compile()` inside
168
170
  `fn`, after `mask` and truncation. If tasks that run at the same time share one prompt object, call
169
171
  `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.
172
+ the cache. If you do not, a task can record the variables of a different task. Tasks that run at the
173
+ same time inside one `withPrompt` share its variables, so call `withPrompt` in each task that
174
+ compiles its own. Model calls are the spans of type `generation` (operations `chat`,
175
+ `text_completion`, and `generate_content`), including the spans of the provider integrations that
176
+ use this SDK: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, and OpenRouter. Spans from the AI SDK
177
+ and TanStack AI integrations, and from other OpenTelemetry tracers, do not get the attributes.
178
+ Messages from `compile()` that you pass to a model call outside `withPrompt` are not recorded.
176
179
 
177
180
  ## API
178
181
 
package/dist/index.d.mts CHANGED
@@ -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.1.12",
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/prompts.ts CHANGED
@@ -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;