telemetry-dev 0.2.11__tar.gz → 0.2.12__tar.gz

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.
Files changed (21) hide show
  1. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/PKG-INFO +71 -2
  2. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/README.md +70 -1
  3. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/pyproject.toml +1 -1
  4. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/__init__.py +18 -0
  6. telemetry_dev-0.2.12/src/telemetry_dev/_prompts.py +679 -0
  7. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_semconv.py +5 -0
  8. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_spans.py +6 -0
  9. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/LICENSE +0 -0
  10. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_capture.py +0 -0
  11. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_client.py +0 -0
  12. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_config.py +0 -0
  13. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_context.py +0 -0
  14. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_export.py +0 -0
  15. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_logs.py +0 -0
  16. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_metrics.py +0 -0
  17. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_observe.py +0 -0
  18. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_processor.py +0 -0
  19. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/_serialize.py +0 -0
  20. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/otel.py +0 -0
  21. {telemetry_dev-0.2.11 → telemetry_dev-0.2.12}/src/telemetry_dev/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev
3
- Version: 0.2.11
3
+ Version: 0.2.12
4
4
  Summary: telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics
5
5
  Keywords: telemetry,opentelemetry,llm,genai,tracing,observability
6
6
  Author: telemetry.dev
@@ -81,7 +81,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
81
81
  | Variable | Default | Purpose |
82
82
  | --- | --- | --- |
83
83
  | `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
84
- | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
84
+ | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
85
85
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
86
86
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
87
87
  | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
@@ -108,6 +108,8 @@ later `init()` generates a new ID. It is not persisted across processes.
108
108
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
109
109
  | `log(message, *, level="info", event_name=None, attributes=None)` | Emit an OTLP log record to `/v1/logs`, correlated with the current trace. Levels: `debug`/`info`/`warn`/`error` (`"warning"` is accepted as an alias of `warn`). |
110
110
  | `get_traceparent() -> str \| None` | W3C traceparent of the current context. |
111
+ | `get_prompt(name, *, label=, version=, type="chat", cache_ttl_seconds=60, fallback=, api_key=, base_url=)` / `get_prompt_async(...)` | Fetch a prompt by label or version; see [Prompts](#prompts). |
112
+ | `with_prompt(prompt)` | Context manager recording the prompt on each model call started inside. |
111
113
  | `flush(timeout_s=10.0)` / `shutdown(timeout_s=10.0)` | Force-flush / tear down traces + logs + metrics. Shutdown also runs atexit unless `disable_atexit=True`. |
112
114
  | `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
113
115
  | `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
@@ -201,6 +203,73 @@ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples session
201
203
  These classes come from `opentelemetry.sdk.trace.sampling`.
202
204
  The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
203
205
 
206
+ ## Prompts
207
+
208
+ `get_prompt` fetches a prompt that you manage in telemetry.dev. Fetch it by its label, so that you
209
+ deploy a version when you move the label, without a change to the code:
210
+
211
+ ```python
212
+ import telemetry_dev
213
+ from openai import OpenAI
214
+ from telemetry_dev_openai import wrap_openai
215
+
216
+ telemetry_dev.init()
217
+ client = wrap_openai(OpenAI())
218
+
219
+ prompt = telemetry_dev.get_prompt("support/reply", label="production")
220
+ messages = prompt.compile(customer_message=customer_message, tone=tone)
221
+
222
+ with telemetry_dev.with_prompt(prompt):
223
+ response = client.chat.completions.create(
224
+ model=prompt.model["name"] if prompt.model else "gpt-4o", messages=messages
225
+ )
226
+
227
+ # Pin one version, as in a test:
228
+ pinned = telemetry_dev.get_prompt("support/reply", version=13)
229
+ ```
230
+
231
+ - Without `label` and `version`, `get_prompt` fetches the `production` label. A text prompt needs
232
+ `type="text"`; `get_prompt` raises for a prompt of the other type.
233
+ - `compile(variables)` or `compile(**variables)` puts the values in place of `{{name}}`. A missing
234
+ value or `None` becomes an empty text, and a value that is not a string becomes JSON. In a chat
235
+ prompt, a placeholder message becomes the list of messages in its variable. A text prompt
236
+ compiles to a string.
237
+ - The prompt also has `model`, `parameters`, `tools`, and `response_format` of its version. The
238
+ keys inside them are the API's (`maxTokens`, `topP`, ...).
239
+ - `get_prompt` keeps each prompt. When the copy is older than `cache_ttl_seconds` (default 60), a
240
+ call returns it at once and fetches a new copy in a background thread. Only a call with no copy
241
+ waits for a fetch, for 10 seconds at most, also when the network stops. Calls for the same
242
+ prompt share one fetch. `get_prompt_async` runs the same function in a worker thread.
243
+ - If a fetch fails, `get_prompt` returns the last copy that it fetched and reports the error
244
+ through `on_error`. With no copy, it returns the `fallback` (`version` is `None` and
245
+ `is_fallback` is `True`). With no fallback, it raises `PromptError` whose `code` tells why, for
246
+ example `prompt_not_found`, `label_not_found`, `insufficient_scope`, `missing_api_key`, or
247
+ `timeout`. A response with a different status and no API error gives `http_<status>`, for
248
+ example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
249
+ redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
250
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. This is the one SDK
251
+ function that can raise: your app cannot run without its prompt.
252
+ - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
253
+ and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
254
+ telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
255
+ prompts.
256
+ - When a label sends part of the traffic to a second version, each session keeps one version: the
257
+ choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
258
+ session id, each call chooses at random.
259
+
260
+ `with_prompt(prompt)` records the prompt on each model call that starts inside it:
261
+ `td.prompt.name`, `td.prompt.version`, and `td.prompt.label` (only for a prompt fetched by label).
262
+ When input capture is on, `td.prompt.variables` holds the JSON of the variables of the last
263
+ `compile()` of that prompt object before `with_prompt` starts, or of the last `compile()` inside
264
+ it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
265
+ object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
266
+ `get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
267
+ of a different thread. Model calls are the spans of type `generation` (operations `chat`,
268
+ `text_completion`, and `generate_content`), including the spans of the provider integrations:
269
+ OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from other
270
+ OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
271
+ model call outside `with_prompt` are not recorded.
272
+
204
273
  ## Auto-metrics
205
274
 
206
275
  Ended spans automatically record histograms (DELTA temporality, exported every 60s):
@@ -56,7 +56,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
56
56
  | Variable | Default | Purpose |
57
57
  | --- | --- | --- |
58
58
  | `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
59
- | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
59
+ | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
60
60
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
61
61
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
62
62
  | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
@@ -83,6 +83,8 @@ later `init()` generates a new ID. It is not persisted across processes.
83
83
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
84
84
  | `log(message, *, level="info", event_name=None, attributes=None)` | Emit an OTLP log record to `/v1/logs`, correlated with the current trace. Levels: `debug`/`info`/`warn`/`error` (`"warning"` is accepted as an alias of `warn`). |
85
85
  | `get_traceparent() -> str \| None` | W3C traceparent of the current context. |
86
+ | `get_prompt(name, *, label=, version=, type="chat", cache_ttl_seconds=60, fallback=, api_key=, base_url=)` / `get_prompt_async(...)` | Fetch a prompt by label or version; see [Prompts](#prompts). |
87
+ | `with_prompt(prompt)` | Context manager recording the prompt on each model call started inside. |
86
88
  | `flush(timeout_s=10.0)` / `shutdown(timeout_s=10.0)` | Force-flush / tear down traces + logs + metrics. Shutdown also runs atexit unless `disable_atexit=True`. |
87
89
  | `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
88
90
  | `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
@@ -176,6 +178,73 @@ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples session
176
178
  These classes come from `opentelemetry.sdk.trace.sampling`.
177
179
  The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
178
180
 
181
+ ## Prompts
182
+
183
+ `get_prompt` fetches a prompt that you manage in telemetry.dev. Fetch it by its label, so that you
184
+ deploy a version when you move the label, without a change to the code:
185
+
186
+ ```python
187
+ import telemetry_dev
188
+ from openai import OpenAI
189
+ from telemetry_dev_openai import wrap_openai
190
+
191
+ telemetry_dev.init()
192
+ client = wrap_openai(OpenAI())
193
+
194
+ prompt = telemetry_dev.get_prompt("support/reply", label="production")
195
+ messages = prompt.compile(customer_message=customer_message, tone=tone)
196
+
197
+ with telemetry_dev.with_prompt(prompt):
198
+ response = client.chat.completions.create(
199
+ model=prompt.model["name"] if prompt.model else "gpt-4o", messages=messages
200
+ )
201
+
202
+ # Pin one version, as in a test:
203
+ pinned = telemetry_dev.get_prompt("support/reply", version=13)
204
+ ```
205
+
206
+ - Without `label` and `version`, `get_prompt` fetches the `production` label. A text prompt needs
207
+ `type="text"`; `get_prompt` raises for a prompt of the other type.
208
+ - `compile(variables)` or `compile(**variables)` puts the values in place of `{{name}}`. A missing
209
+ value or `None` becomes an empty text, and a value that is not a string becomes JSON. In a chat
210
+ prompt, a placeholder message becomes the list of messages in its variable. A text prompt
211
+ compiles to a string.
212
+ - The prompt also has `model`, `parameters`, `tools`, and `response_format` of its version. The
213
+ keys inside them are the API's (`maxTokens`, `topP`, ...).
214
+ - `get_prompt` keeps each prompt. When the copy is older than `cache_ttl_seconds` (default 60), a
215
+ call returns it at once and fetches a new copy in a background thread. Only a call with no copy
216
+ waits for a fetch, for 10 seconds at most, also when the network stops. Calls for the same
217
+ prompt share one fetch. `get_prompt_async` runs the same function in a worker thread.
218
+ - If a fetch fails, `get_prompt` returns the last copy that it fetched and reports the error
219
+ through `on_error`. With no copy, it returns the `fallback` (`version` is `None` and
220
+ `is_fallback` is `True`). With no fallback, it raises `PromptError` whose `code` tells why, for
221
+ example `prompt_not_found`, `label_not_found`, `insufficient_scope`, `missing_api_key`, or
222
+ `timeout`. A response with a different status and no API error gives `http_<status>`, for
223
+ example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
224
+ redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
225
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. This is the one SDK
226
+ function that can raise: your app cannot run without its prompt.
227
+ - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
228
+ and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
229
+ telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
230
+ prompts.
231
+ - When a label sends part of the traffic to a second version, each session keeps one version: the
232
+ choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
233
+ session id, each call chooses at random.
234
+
235
+ `with_prompt(prompt)` records the prompt on each model call that starts inside it:
236
+ `td.prompt.name`, `td.prompt.version`, and `td.prompt.label` (only for a prompt fetched by label).
237
+ When input capture is on, `td.prompt.variables` holds the JSON of the variables of the last
238
+ `compile()` of that prompt object before `with_prompt` starts, or of the last `compile()` inside
239
+ it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
240
+ object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
241
+ `get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
242
+ of a different thread. Model calls are the spans of type `generation` (operations `chat`,
243
+ `text_completion`, and `generate_content`), including the spans of the provider integrations:
244
+ OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from other
245
+ OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
246
+ model call outside `with_prompt` are not recorded.
247
+
179
248
  ## Auto-metrics
180
249
 
181
250
  Ended spans automatically record histograms (DELTA temporality, exported every 60s):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.11"
3
+ version = "0.2.12"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.11"
3
+ version = "0.2.12"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -19,6 +19,16 @@ from ._config import LogLevelOption
19
19
  from ._context import get_traceparent, propagate_attributes
20
20
  from ._logs import log
21
21
  from ._observe import observe
22
+ from ._prompts import (
23
+ ChatPrompt,
24
+ Prompt,
25
+ PromptError,
26
+ PromptModel,
27
+ TextPrompt,
28
+ get_prompt,
29
+ get_prompt_async,
30
+ with_prompt,
31
+ )
22
32
  from ._semconv import LogLevel, SpanType
23
33
  from ._serialize import AttributeValue, Mask, MaskContext
24
34
  from ._spans import (
@@ -34,18 +44,25 @@ __all__ = [
34
44
  "NOT_GIVEN",
35
45
  "AttributeValue",
36
46
  "CaptureBudget",
47
+ "ChatPrompt",
37
48
  "Client",
38
49
  "LogLevel",
39
50
  "LogLevelOption",
40
51
  "Mask",
41
52
  "MaskContext",
53
+ "Prompt",
54
+ "PromptError",
55
+ "PromptModel",
42
56
  "SpanHandle",
43
57
  "SpanType",
44
58
  "TelemetrySpanProcessor",
59
+ "TextPrompt",
45
60
  "Usage",
46
61
  "__version__",
47
62
  "flush",
48
63
  "get_client",
64
+ "get_prompt",
65
+ "get_prompt_async",
49
66
  "get_traceparent",
50
67
  "init",
51
68
  "log",
@@ -54,4 +71,5 @@ __all__ = [
54
71
  "shutdown",
55
72
  "start_span",
56
73
  "update_current_span",
74
+ "with_prompt",
57
75
  ]
@@ -0,0 +1,679 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import hashlib
6
+ import json
7
+ import random
8
+ import re
9
+ import threading
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from collections import OrderedDict
14
+ from collections.abc import Callable, Generator, Mapping
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass, field
17
+ from email.message import Message
18
+ from time import monotonic
19
+ from typing import IO, Any, Literal, TypeAlias, TypedDict, cast, overload
20
+ from weakref import WeakKeyDictionary
21
+
22
+ from opentelemetry import context as otel_context
23
+ from opentelemetry.context import Context
24
+
25
+ from ._client import Client, get_client
26
+ from ._config import report_error, resolve_config
27
+ from ._context import propagated_attributes
28
+ from ._semconv import (
29
+ ATTR_PROMPT_LABEL,
30
+ ATTR_PROMPT_NAME,
31
+ ATTR_PROMPT_VARIABLES,
32
+ ATTR_PROMPT_VERSION,
33
+ ATTR_SESSION_ID,
34
+ )
35
+ from ._serialize import AttributeValue, json_dumps
36
+
37
+ _FETCH_TIMEOUT_S = 10.0
38
+ # How long a failed fetch with no copy answers calls, so that they do not each fetch again.
39
+ _ERROR_TTL_S = 10.0
40
+ _MAX_CACHE_ENTRIES = 1000
41
+ _VARIABLE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
42
+ _MODEL_CALL_OPERATIONS = frozenset({"chat", "text_completion", "generate_content"})
43
+ _PROMPT_KEY = otel_context.create_key("telemetry_dev.prompt")
44
+
45
+
46
+ class PromptModel(TypedDict):
47
+ provider: str
48
+ name: str
49
+
50
+
51
+ class PromptError(Exception):
52
+ """Why get_prompt failed. ``code`` is the code of the API error (``prompt_not_found``,
53
+ ``label_not_found``, ``insufficient_scope``, ``invalid_api_key``, ...), or
54
+ ``missing_api_key``, ``timeout``, ``network_error``, ``invalid_response``, or
55
+ ``type_mismatch``. A response with a different status and no API error gives
56
+ ``http_<status>``, for example ``http_302`` for a redirect: get_prompt does not go to the
57
+ location of a redirect. ``status`` is the HTTP status of the response."""
58
+
59
+ def __init__(self, code: str, message: str, status: int | None = None) -> None:
60
+ super().__init__(message)
61
+ self.code = code
62
+ self.message = message
63
+ self.status = status
64
+
65
+
66
+ @dataclass(frozen=True, eq=False)
67
+ class ChatPrompt:
68
+ """A chat prompt version. ``compile()`` makes the messages to send to the model."""
69
+
70
+ name: str
71
+ version: int | None
72
+ """The version that the app got; None for the fallback."""
73
+ label: str | None
74
+ """The label that the prompt was fetched by; None when it was fetched by version."""
75
+ messages: list[dict[str, Any]]
76
+ model: PromptModel | None
77
+ parameters: dict[str, Any]
78
+ tools: list[dict[str, Any]]
79
+ response_format: dict[str, Any]
80
+ is_fallback: bool
81
+ """True when get_prompt could not fetch the prompt and returned the ``fallback``."""
82
+ type: Literal["chat"] = field(default="chat", init=False)
83
+ text: None = field(default=None, init=False)
84
+
85
+ def compile(
86
+ self, variables: Mapping[str, Any] | None = None, /, **kwargs: Any
87
+ ) -> list[dict[str, Any]]:
88
+ """Fill in the variables and replace each placeholder message with the list of messages
89
+ in its variable. ``with_prompt`` records the variables of the last call before it starts
90
+ or inside it."""
91
+ values = _record(self, variables, kwargs)
92
+ compiled: list[dict[str, Any]] = []
93
+ for message in self.messages:
94
+ if message.get("role") != "placeholder":
95
+ compiled.append(
96
+ {"role": message["role"], "content": _fill(str(message["content"]), values)}
97
+ )
98
+ continue
99
+ name = str(message["name"])
100
+ value = values.get(name)
101
+ if value is None:
102
+ continue
103
+ if not isinstance(value, list | tuple):
104
+ raise TypeError(f"The {name} variable must be a list of messages")
105
+ compiled.extend(cast("list[dict[str, Any]] | tuple[dict[str, Any], ...]", value))
106
+ return compiled
107
+
108
+
109
+ @dataclass(frozen=True, eq=False)
110
+ class TextPrompt:
111
+ """A text prompt version. ``compile()`` makes the text to send to the model."""
112
+
113
+ name: str
114
+ version: int | None
115
+ """The version that the app got; None for the fallback."""
116
+ label: str | None
117
+ """The label that the prompt was fetched by; None when it was fetched by version."""
118
+ text: str
119
+ model: PromptModel | None
120
+ parameters: dict[str, Any]
121
+ tools: list[dict[str, Any]]
122
+ response_format: dict[str, Any]
123
+ is_fallback: bool
124
+ """True when get_prompt could not fetch the prompt and returned the ``fallback``."""
125
+ type: Literal["text"] = field(default="text", init=False)
126
+
127
+ @property
128
+ def messages(self) -> list[dict[str, Any]]:
129
+ return []
130
+
131
+ def compile(self, variables: Mapping[str, Any] | None = None, /, **kwargs: Any) -> str:
132
+ """Fill in the variables. ``with_prompt`` records the variables of the last call before it
133
+ starts or inside it."""
134
+ return _fill(self.text, _record(self, variables, kwargs))
135
+
136
+
137
+ Prompt: TypeAlias = ChatPrompt | TextPrompt
138
+
139
+
140
+ @overload
141
+ def get_prompt(
142
+ name: str,
143
+ *,
144
+ type: Literal["chat"] = "chat",
145
+ label: str | None = None,
146
+ version: int | None = None,
147
+ cache_ttl_seconds: float = 60,
148
+ fallback: list[dict[str, Any]] | None = None,
149
+ api_key: str | None = None,
150
+ base_url: str | None = None,
151
+ ) -> ChatPrompt: ...
152
+
153
+
154
+ @overload
155
+ def get_prompt(
156
+ name: str,
157
+ *,
158
+ type: Literal["text"],
159
+ label: str | None = None,
160
+ version: int | None = None,
161
+ cache_ttl_seconds: float = 60,
162
+ fallback: str | None = None,
163
+ api_key: str | None = None,
164
+ base_url: str | None = None,
165
+ ) -> TextPrompt: ...
166
+
167
+
168
+ def get_prompt(
169
+ name: str,
170
+ *,
171
+ type: Literal["chat", "text"] = "chat",
172
+ label: str | None = None,
173
+ version: int | None = None,
174
+ cache_ttl_seconds: float = 60,
175
+ fallback: list[dict[str, Any]] | str | None = None,
176
+ api_key: str | None = None,
177
+ base_url: str | None = None,
178
+ ) -> Prompt:
179
+ """Fetch a prompt version from telemetry.dev and keep it.
180
+
181
+ Without ``label`` and ``version`` it fetches the ``production`` label. When the copy is older
182
+ than ``cache_ttl_seconds``, a call returns it at once and fetches a new one in the background.
183
+ Only a call with no copy waits for a fetch, at most 10 seconds. Calls for the same prompt share
184
+ one fetch. When a fetch fails, it returns the last copy that it kept; with no copy it returns
185
+ ``fallback``. With neither it raises ``PromptError``: unlike the telemetry functions,
186
+ get_prompt cannot fail open, because the app cannot run without the prompt. With no copy, the
187
+ calls of the next 10 seconds (or the TTL, if it is shorter) fail the same way without a fetch.
188
+
189
+ A label with a gradual rollout gives each session one version: the choice uses the session
190
+ id of ``propagate_attributes``, or a random choice per call without one.
191
+ """
192
+ return _get_prompt(name, type, label, version, cache_ttl_seconds, fallback, api_key, base_url)
193
+
194
+
195
+ @overload
196
+ async def get_prompt_async(
197
+ name: str,
198
+ *,
199
+ type: Literal["chat"] = "chat",
200
+ label: str | None = None,
201
+ version: int | None = None,
202
+ cache_ttl_seconds: float = 60,
203
+ fallback: list[dict[str, Any]] | None = None,
204
+ api_key: str | None = None,
205
+ base_url: str | None = None,
206
+ ) -> ChatPrompt: ...
207
+
208
+
209
+ @overload
210
+ async def get_prompt_async(
211
+ name: str,
212
+ *,
213
+ type: Literal["text"],
214
+ label: str | None = None,
215
+ version: int | None = None,
216
+ cache_ttl_seconds: float = 60,
217
+ fallback: str | None = None,
218
+ api_key: str | None = None,
219
+ base_url: str | None = None,
220
+ ) -> TextPrompt: ...
221
+
222
+
223
+ async def get_prompt_async(
224
+ name: str,
225
+ *,
226
+ type: Literal["chat", "text"] = "chat",
227
+ label: str | None = None,
228
+ version: int | None = None,
229
+ cache_ttl_seconds: float = 60,
230
+ fallback: list[dict[str, Any]] | str | None = None,
231
+ api_key: str | None = None,
232
+ base_url: str | None = None,
233
+ ) -> Prompt:
234
+ """``get_prompt`` in a worker thread, so a fetch does not block the event loop."""
235
+ return await asyncio.to_thread(
236
+ _get_prompt, name, type, label, version, cache_ttl_seconds, fallback, api_key, base_url
237
+ )
238
+
239
+
240
+ @contextmanager
241
+ def with_prompt(prompt: Prompt) -> Generator[None]:
242
+ """Record ``prompt`` on each model call (generation span) started inside this context:
243
+ ``td.prompt.name``, ``td.prompt.version``, ``td.prompt.label``, and, when input capture is on,
244
+ ``td.prompt.variables``. The variables are those of the last ``compile()`` of this prompt
245
+ object before the context starts, or of the last ``compile()`` inside it, so a compile in
246
+ another thread or task does not change them once the context runs."""
247
+ scope = _PromptScope(prompt, _compiled_variables.get(prompt))
248
+ token = otel_context.attach(otel_context.set_value(_PROMPT_KEY, scope))
249
+ try:
250
+ yield
251
+ finally:
252
+ otel_context.detach(token)
253
+
254
+
255
+ def prompt_attributes(
256
+ client: Client, context: Context | None, operation: object, capture_input: bool
257
+ ) -> dict[str, AttributeValue]:
258
+ """The td.prompt.* attributes for a span with this operation that starts in ``context``."""
259
+ if operation not in _MODEL_CALL_OPERATIONS:
260
+ return {}
261
+ scope = otel_context.get_value(_PROMPT_KEY, context) or otel_context.get_value(_PROMPT_KEY)
262
+ if not isinstance(scope, _PromptScope):
263
+ return {}
264
+ prompt = scope.prompt
265
+ attrs: dict[str, AttributeValue] = {ATTR_PROMPT_NAME: prompt.name}
266
+ if prompt.version is not None:
267
+ attrs[ATTR_PROMPT_VERSION] = prompt.version
268
+ if prompt.label is not None:
269
+ attrs[ATTR_PROMPT_LABEL] = prompt.label
270
+ if scope.variables is not None and capture_input:
271
+ serialized = client.serialize(scope.variables, ATTR_PROMPT_VARIABLES)
272
+ if serialized is not None:
273
+ attrs[ATTR_PROMPT_VARIABLES] = serialized
274
+ return attrs
275
+
276
+
277
+ def _get_prompt(
278
+ name: str,
279
+ type: Literal["chat", "text"],
280
+ label: str | None,
281
+ version: int | None,
282
+ cache_ttl_seconds: float,
283
+ fallback: list[dict[str, Any]] | str | None,
284
+ api_key: str | None,
285
+ base_url: str | None,
286
+ ) -> Prompt:
287
+ _check(name, type, label, version, cache_ttl_seconds, fallback)
288
+ client = get_client()
289
+ enabled = client if client is not None and client.enabled else None
290
+ config = enabled.config if enabled is not None else resolve_config()
291
+ key = api_key if api_key is not None else config.api_key
292
+ url = (base_url or config.base_url).rstrip("/")
293
+ asked_label = (label if label is not None else "production") if version is None else None
294
+ on_error = enabled.on_error if enabled is not None else None
295
+
296
+ try:
297
+ if not key:
298
+ raise PromptError(
299
+ "missing_api_key", "No API key: pass api_key or set TELEMETRY_DEV_API_KEY"
300
+ )
301
+ query = {"name": name}
302
+ if asked_label is None:
303
+ query["version"] = str(version)
304
+ else:
305
+ query["label"] = asked_label
306
+ response = _load(
307
+ (
308
+ url,
309
+ hashlib.sha256(key.encode()).hexdigest()[:32],
310
+ name,
311
+ f"version:{version}" if asked_label is None else f"label:{asked_label}",
312
+ ),
313
+ cache_ttl_seconds,
314
+ lambda: _fetch(
315
+ f"{url}/v1/prompts?{urllib.parse.urlencode(query)}",
316
+ {"Authorization": f"Bearer {key}", "X-Telemetry-Dev-SDK": "telemetry-dev"},
317
+ ),
318
+ on_error,
319
+ )
320
+ except Exception as exc:
321
+ if fallback is None:
322
+ raise
323
+ report_error(on_error, "fetching the prompt failed; using the fallback", exc)
324
+ content: dict[str, Any] = {
325
+ "messages" if type == "chat" else "text": fallback,
326
+ "model": None,
327
+ "parameters": {},
328
+ "tools": [],
329
+ "responseFormat": {"type": "text"},
330
+ }
331
+ return _make_prompt(name, type, asked_label, None, content, True)
332
+
333
+ if response["type"] != type:
334
+ raise PromptError(
335
+ "type_mismatch",
336
+ f'{name} is a {response["type"]} prompt; pass type="{response["type"]}"',
337
+ )
338
+ rollout = response.get("rollout")
339
+ chosen: Mapping[str, Any] = response
340
+ if rollout is not None and _in_rollout(response, rollout["percent"], enabled):
341
+ chosen = rollout
342
+ return _make_prompt(response["name"], type, response["label"], chosen["version"], chosen, False)
343
+
344
+
345
+ _compiled_variables: WeakKeyDictionary[ChatPrompt | TextPrompt, dict[str, Any]] = (
346
+ WeakKeyDictionary()
347
+ )
348
+
349
+
350
+ @dataclass
351
+ class _PromptScope:
352
+ """The prompt of a ``with_prompt`` context and the variables that its spans record."""
353
+
354
+ prompt: Prompt
355
+ variables: dict[str, Any] | None
356
+
357
+
358
+ def _record(
359
+ prompt: Prompt, variables: Mapping[str, Any] | None, kwargs: Mapping[str, Any]
360
+ ) -> dict[str, Any]:
361
+ values = {**(variables or {}), **kwargs}
362
+ _compiled_variables[prompt] = values
363
+ scope = otel_context.get_value(_PROMPT_KEY)
364
+ if isinstance(scope, _PromptScope) and scope.prompt is prompt:
365
+ scope.variables = values
366
+ return values
367
+
368
+
369
+ def _fill(text: str, values: Mapping[str, Any]) -> str:
370
+ def replace(match: re.Match[str]) -> str:
371
+ value = values.get(match.group(1))
372
+ if value is None:
373
+ return ""
374
+ return value if isinstance(value, str) else json_dumps(value)
375
+
376
+ return _VARIABLE.sub(replace, text)
377
+
378
+
379
+ def _check(
380
+ name: object,
381
+ type: object,
382
+ label: str | None,
383
+ version: object,
384
+ cache_ttl_seconds: object,
385
+ fallback: object,
386
+ ) -> None:
387
+ if not isinstance(name, str) or not name:
388
+ raise TypeError("name must be a non-empty string")
389
+ if type not in ("chat", "text"):
390
+ raise TypeError('type must be "chat" or "text"')
391
+ if label is not None and version is not None:
392
+ raise TypeError("Pass label or version, not both")
393
+ if version is not None and (
394
+ isinstance(version, bool) or not isinstance(version, int) or version < 1
395
+ ):
396
+ raise ValueError("version must be a positive integer")
397
+ if (
398
+ isinstance(cache_ttl_seconds, bool)
399
+ or not isinstance(cache_ttl_seconds, int | float)
400
+ or not 0 <= cache_ttl_seconds < float("inf")
401
+ ):
402
+ raise ValueError("cache_ttl_seconds must be a non-negative number")
403
+ if fallback is not None and not isinstance(fallback, list if type == "chat" else str):
404
+ raise TypeError(
405
+ "fallback must be a list of messages" if type == "chat" else "fallback must be a string"
406
+ )
407
+
408
+
409
+ @dataclass
410
+ class _Entry:
411
+ response: dict[str, Any] | None = None
412
+ # Why the last fetch failed, when there was no response to keep.
413
+ error: Exception | None = None
414
+ # When the last fetch ended.
415
+ checked_at: float = 0.0
416
+ # Set when the running fetch ends.
417
+ pending: threading.Event | None = None
418
+ # When calls stop waiting for the running fetch.
419
+ deadline: float = 0.0
420
+
421
+
422
+ _cache: OrderedDict[tuple[str, str, str, str], _Entry] = OrderedDict()
423
+ _cache_lock = threading.Lock()
424
+
425
+
426
+ def _load(
427
+ key: tuple[str, str, str, str],
428
+ ttl_seconds: float,
429
+ fetch: Callable[[], dict[str, Any]],
430
+ on_error: Callable[[BaseException], None] | None,
431
+ ) -> dict[str, Any]:
432
+ """Return the kept copy of a prompt, and fetch a new copy in a background thread when it is
433
+ older than the TTL. Only a call with no copy waits for a fetch, until the fetch's deadline: the
434
+ timeout of urllib applies to each socket operation, not to the whole fetch. A failed fetch with
435
+ no copy fails the calls of the next ``_ERROR_TTL_S`` (or the TTL, if it is shorter) without a
436
+ fetch."""
437
+ with _cache_lock:
438
+ entry = _cache.get(key)
439
+ if entry is None:
440
+ if len(_cache) >= _MAX_CACHE_ENTRIES:
441
+ _cache.popitem(last=False)
442
+ entry = _cache[key] = _Entry()
443
+ now = monotonic()
444
+ ttl = ttl_seconds if entry.response is not None else min(ttl_seconds, _ERROR_TTL_S)
445
+ if now >= entry.checked_at + ttl and entry.pending is None:
446
+ entry.pending = threading.Event()
447
+ entry.deadline = now + _FETCH_TIMEOUT_S
448
+ threading.Thread(
449
+ target=_refresh, args=(entry, entry.pending, fetch, on_error), daemon=True
450
+ ).start()
451
+ if entry.response is not None:
452
+ return entry.response
453
+ pending, deadline = entry.pending, entry.deadline
454
+ if pending is not None and not pending.wait(deadline - monotonic()):
455
+ raise PromptError("timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s")
456
+ with _cache_lock:
457
+ response, error = entry.response, entry.error
458
+ if response is not None:
459
+ return response
460
+ assert error is not None # A fetch that ends with no copy keeps its error.
461
+ # Raise a new exception: each raise of one exception object adds to its traceback.
462
+ if isinstance(error, PromptError):
463
+ raise PromptError(error.code, error.message, error.status) from error
464
+ raise PromptError("network_error", "Could not reach telemetry.dev") from error
465
+
466
+
467
+ def _refresh(
468
+ entry: _Entry,
469
+ done: threading.Event,
470
+ fetch: Callable[[], dict[str, Any]],
471
+ on_error: Callable[[BaseException], None] | None,
472
+ ) -> None:
473
+ response: dict[str, Any] | None = None
474
+ error: Exception | None = None
475
+ try:
476
+ response = fetch()
477
+ except Exception as exc:
478
+ error = exc
479
+ with _cache_lock:
480
+ stale = entry.response
481
+ if response is not None:
482
+ entry.response = response
483
+ elif stale is None:
484
+ entry.error = error
485
+ # A failed fetch counts too, so that calls do not each start a fetch while it fails.
486
+ entry.checked_at = monotonic()
487
+ entry.pending = None
488
+ if error is not None and stale is not None:
489
+ report_error(on_error, "fetching the prompt failed; using the last copy", error)
490
+ done.set()
491
+
492
+
493
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
494
+ # A redirect to another host must not get the API key, so a 3xx fails like any status that is
495
+ # not 2xx.
496
+ def redirect_request(
497
+ self,
498
+ req: urllib.request.Request,
499
+ fp: IO[bytes],
500
+ code: int,
501
+ msg: str,
502
+ headers: Message,
503
+ newurl: str,
504
+ ) -> urllib.request.Request | None:
505
+ return None
506
+
507
+
508
+ _opener = urllib.request.build_opener(_NoRedirect)
509
+
510
+
511
+ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
512
+ request = urllib.request.Request(url, headers=headers, method="GET")
513
+ try:
514
+ with _opener.open(request, timeout=_FETCH_TIMEOUT_S) as response:
515
+ body: bytes = response.read()
516
+ except urllib.error.HTTPError as exc:
517
+ with exc:
518
+ raise _api_error(exc.code, exc.read()) from None
519
+ except TimeoutError as exc:
520
+ raise PromptError(
521
+ "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
522
+ ) from exc
523
+ except (urllib.error.URLError, OSError) as exc:
524
+ if isinstance(getattr(exc, "reason", None), TimeoutError):
525
+ raise PromptError(
526
+ "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
527
+ ) from exc
528
+ raise PromptError("network_error", "Could not reach telemetry.dev") from exc
529
+ try:
530
+ parsed = _parse_response(json.loads(body))
531
+ except ValueError:
532
+ parsed = None
533
+ if parsed is None:
534
+ raise PromptError("invalid_response", "telemetry.dev returned a prompt it cannot read")
535
+ return parsed
536
+
537
+
538
+ def _api_error(status: int, body: bytes) -> PromptError:
539
+ try:
540
+ error = json.loads(body).get("error")
541
+ except (ValueError, AttributeError):
542
+ error = None
543
+ fields = cast("dict[str, Any]", error) if isinstance(error, dict) else {}
544
+ code = fields.get("code")
545
+ message = fields.get("message")
546
+ return PromptError(
547
+ code if isinstance(code, str) else f"http_{status}",
548
+ message if isinstance(message, str) else f"telemetry.dev returned {status}",
549
+ status,
550
+ )
551
+
552
+
553
+ def _parse_response(data: object) -> dict[str, Any] | None:
554
+ if not isinstance(data, dict):
555
+ return None
556
+ body = cast("dict[str, Any]", data)
557
+ prompt_type = body.get("type")
558
+ label = body.get("label")
559
+ if (
560
+ not isinstance(body.get("name"), str)
561
+ or prompt_type not in ("chat", "text")
562
+ or not (label is None or isinstance(label, str))
563
+ or not _is_version(body.get("version"))
564
+ or not _valid_content(body, prompt_type)
565
+ ):
566
+ return None
567
+ rollout = body.get("rollout")
568
+ if rollout is None:
569
+ return body
570
+ if not isinstance(rollout, dict):
571
+ return None
572
+ rollout_body = cast("dict[str, Any]", rollout)
573
+ percent = rollout_body.get("percent")
574
+ if (
575
+ not _is_version(rollout_body.get("version"))
576
+ or isinstance(percent, bool)
577
+ or not isinstance(percent, int)
578
+ or not 0 <= percent <= 100
579
+ or not _valid_content(rollout_body, prompt_type)
580
+ ):
581
+ return None
582
+ return body
583
+
584
+
585
+ def _valid_content(body: Mapping[str, Any], prompt_type: str) -> bool:
586
+ messages = body.get("messages")
587
+ model = body.get("model")
588
+ tools = body.get("tools")
589
+ response_format = body.get("responseFormat")
590
+ valid_body = (
591
+ isinstance(messages, list) and all(_is_message(m) for m in cast("list[Any]", messages))
592
+ if prompt_type == "chat"
593
+ else isinstance(body.get("text"), str)
594
+ )
595
+ return (
596
+ valid_body
597
+ and isinstance(model, dict)
598
+ and isinstance(cast("dict[str, Any]", model).get("provider"), str)
599
+ and isinstance(cast("dict[str, Any]", model).get("name"), str)
600
+ and isinstance(body.get("parameters"), dict)
601
+ and isinstance(tools, list)
602
+ and all(
603
+ isinstance(tool, dict) and isinstance(cast("dict[str, Any]", tool).get("name"), str)
604
+ for tool in cast("list[Any]", tools)
605
+ )
606
+ and isinstance(response_format, dict)
607
+ and isinstance(cast("dict[str, Any]", response_format).get("type"), str)
608
+ )
609
+
610
+
611
+ def _is_message(value: object) -> bool:
612
+ if not isinstance(value, dict):
613
+ return False
614
+ message = cast("dict[str, Any]", value)
615
+ if message.get("role") == "placeholder":
616
+ return isinstance(message.get("name"), str)
617
+ return message.get("role") in ("system", "user", "assistant") and isinstance(
618
+ message.get("content"), str
619
+ )
620
+
621
+
622
+ def _is_version(value: object) -> bool:
623
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 1
624
+
625
+
626
+ def _make_prompt(
627
+ name: str,
628
+ prompt_type: Literal["chat", "text"],
629
+ label: str | None,
630
+ version: int | None,
631
+ content: Mapping[str, Any],
632
+ is_fallback: bool,
633
+ ) -> Prompt:
634
+ # Each prompt gets its own copy, so an app cannot change the cached response through it.
635
+ model: PromptModel | None = copy.deepcopy(content["model"])
636
+ parameters: dict[str, Any] = copy.deepcopy(content["parameters"])
637
+ tools: list[dict[str, Any]] = copy.deepcopy(content["tools"])
638
+ response_format: dict[str, Any] = copy.deepcopy(content["responseFormat"])
639
+ if prompt_type == "chat":
640
+ return ChatPrompt(
641
+ name=name,
642
+ version=version,
643
+ label=label,
644
+ messages=copy.deepcopy(content["messages"]),
645
+ model=model,
646
+ parameters=parameters,
647
+ tools=tools,
648
+ response_format=response_format,
649
+ is_fallback=is_fallback,
650
+ )
651
+ return TextPrompt(
652
+ name=name,
653
+ version=version,
654
+ label=label,
655
+ text=content["text"],
656
+ model=model,
657
+ parameters=parameters,
658
+ tools=tools,
659
+ response_format=response_format,
660
+ is_fallback=is_fallback,
661
+ )
662
+
663
+
664
+ def _in_rollout(response: Mapping[str, Any], percent: int, client: Client | None) -> bool:
665
+ session_id = propagated_attributes().get(ATTR_SESSION_ID)
666
+ if not isinstance(session_id, str) and client is not None:
667
+ session_id = client._process_session_id # pyright: ignore[reportPrivateUsage]
668
+ if not isinstance(session_id, str):
669
+ return random.randrange(100) < percent
670
+ return _fnv1a(f"{response['name']}:{response['label']}:{session_id}") % 100 < percent
671
+
672
+
673
+ def _fnv1a(text: str) -> int:
674
+ """FNV-1a 32-bit over the UTF-8 bytes, as TextEncoder makes them in the TypeScript SDK."""
675
+ data = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace").encode()
676
+ value = 2166136261
677
+ for byte in data:
678
+ value = ((value ^ byte) * 16777619) & 0xFFFFFFFF
679
+ return value
@@ -84,6 +84,11 @@ ATTR_TIME_TO_FIRST_CHUNK = "gen_ai.response.time_to_first_chunk"
84
84
 
85
85
  METADATA_PREFIX = "td.metadata."
86
86
 
87
+ ATTR_PROMPT_NAME = "td.prompt.name"
88
+ ATTR_PROMPT_VERSION = "td.prompt.version"
89
+ ATTR_PROMPT_LABEL = "td.prompt.label"
90
+ ATTR_PROMPT_VARIABLES = "td.prompt.variables"
91
+
87
92
  USAGE_ATTRS: dict[str, str] = {
88
93
  "input_tokens": "gen_ai.usage.input_tokens",
89
94
  "output_tokens": "gen_ai.usage.output_tokens",
@@ -29,6 +29,7 @@ from ._context import (
29
29
  traceparent_of,
30
30
  with_session_parent,
31
31
  )
32
+ from ._prompts import prompt_attributes
32
33
  from ._semconv import (
33
34
  ATTR_AGENT_ID,
34
35
  ATTR_AGENT_NAME,
@@ -743,6 +744,11 @@ def start_span(
743
744
  ctx, session_id if isinstance(session_id, str) else None, client.config.api_key
744
745
  )
745
746
  initial.update(propagated)
747
+ initial.update(
748
+ prompt_attributes(
749
+ client, ctx, attrs.get(ATTR_OPERATION, operation), state.capture_input
750
+ )
751
+ )
746
752
  initial.update(attrs)
747
753
  span = client.tracer.start_span(
748
754
  name,
File without changes