traceAI-livekit 0.1.1__tar.gz → 0.1.2__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.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.3
2
+ Name: traceAI-livekit
3
+ Version: 0.1.2
4
+ Summary: OpenTelemetry instrumentation for LiveKit
5
+ Author: Future AGI
6
+ Author-email: no-reply@futureagi.com
7
+ Requires-Python: >3.9,<3.14
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: fi-instrumentation-otel (>=1.1.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # traceAI-livekit
17
+
18
+ OpenTelemetry instrumentation for LiveKit agents integrated with Future AGI.
19
+
20
+ This package provides automatic attribute mapping from LiveKit's native OpenTelemetry instrumentation to Future AGI's semantic conventions, ensuring rich and structured traces in your dashboard.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install traceai-livekit
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ To instrument your LiveKit agent, simply initialize `fi-instrumentation` and enable the attribute mapping **inside your agent's entrypoint**.
31
+
32
+ ```python
33
+ from livekit.agents import AgentServer, JobContext
34
+ from fi_instrumentation.otel import register, ProjectType
35
+ from traceai_livekit import enable_http_attribute_mapping
36
+
37
+ server = AgentServer()
38
+
39
+ @server.rtc_session()
40
+ async def entrypoint(ctx: JobContext):
41
+ # 1. Initialize TraceAI (inside entrypoint for multiprocessing safety)
42
+ register(
43
+ project_name="My LiveKit Agent",
44
+ project_type=ProjectType.OBSERVE,
45
+ set_global_tracer_provider=True,
46
+ )
47
+
48
+ # 2. Enable LiveKit attribute mapping
49
+ # This automatically hooks into LiveKit's telemetry system
50
+ enable_http_attribute_mapping()
51
+
52
+ # ... rest of your agent logic ...
53
+ await ctx.connect()
54
+
55
+ if __name__ == "__main__":
56
+ server.run()
57
+ ```
58
+
59
+ `enable_http_attribute_mapping()` (or `enable_grpc_attribute_mapping()`) hands the provider to LiveKit for you. If you call
60
+ LiveKit's `set_tracer_provider` yourself on the provider `register()` returned, call it after `enable_*`: before it, the call
61
+ drops the Future AGI exporter. LiveKit installs its privacy filter once per provider, so an `allow_pii` passed in a later call is
62
+ ignored. To keep conversation content out of your traces, set `LIVEKIT_TELEMETRY_ALLOW_PII=false` before calling `enable_*`
63
+ (livekit-agents 1.8 and later).
64
+
65
+ ### Flushing at the end of a job
66
+
67
+ On Linux a LiveKit job process ends without running `atexit`, and LiveKit flushes only its own processors, so the spans
68
+ still queued when a job ends, such as the root `agent_session` span, used to never reach Future AGI. `enable_*` now sends
69
+ them twice over:
70
+
71
+ - **When the job shuts down**, if `enable_*` runs inside a job (the usual place, the entrypoint): this sends the voice
72
+ trace, `agent_session` included, as soon as the call ends.
73
+ - **When the process exits**, just before it stops: this sends what LiveKit ends after the shutdown callbacks, its own
74
+ `job_shutdown` span, and the job's root `job_entrypoint` span when tracing is set up before the job starts. With
75
+ set-up inside the entrypoint, LiveKit starts `job_entrypoint` before tracing is on, so it is never recorded and the
76
+ job trace arrives rooted at `job_shutdown`.
77
+
78
+ Each waits at most 3 seconds for the collector, well inside the 10 seconds LiveKit gives a job process to exit, so its own
79
+ clean-up still runs when the collector is slow.
80
+
81
+ If you set tracing up outside a job (for example at import time, in the job process), only the process-exit flush runs.
82
+ To also send each call's trace as soon as the call ends, call `flush_on_job_shutdown()` in each job entrypoint:
83
+
84
+ ```python
85
+ from traceai_livekit import flush_on_job_shutdown
86
+
87
+ @server.rtc_session()
88
+ async def entrypoint(ctx: JobContext):
89
+ flush_on_job_shutdown()
90
+ ...
91
+ ```
92
+
93
+ Do not pass `provider.force_flush` to `ctx.add_shutdown_callback`: LiveKit calls it with the shutdown reason, which
94
+ `force_flush` takes as its timeout, so it fails and nothing is flushed.
95
+
96
+ Limits:
97
+ - A process LiveKit kills (for example when a worker drains and the job overruns its exit time) sends nothing more.
98
+ - Before livekit-agents 1.3, LiveKit closes the session at the same time as the job-shutdown flush, so that flush can miss
99
+ `agent_session`; the process-exit flush still sends it.
100
+ - A second `AgentSession` in one job that is still closing when the shutdown callbacks run is sent by the process-exit
101
+ flush, not the job-shutdown one.
102
+ - In LiveKit Cloud observability mode the session's parent span is recorded on LiveKit's own provider and never reaches
103
+ Future AGI, so the trace has no stored root there.
104
+
105
+ ## Compatibility
106
+
107
+ * **livekit-agents**: 1.x. 1.8 is the tested version (it needs Python 3.10 or later). Since 1.7, LiveKit names its content
108
+ attributes `lk.pii.*` (for example `lk.pii.user_input`); both those names and the earlier ones are mapped to `input.value`
109
+ and `output.value`.
110
+ * **fi-instrumentation-otel**: 1.1.0 or later (1.0.0 fails inside `register()` with OpenTelemetry 1.45, which livekit-agents
111
+ 1.8.7 and later require). 1.0 moved span attributes from the `llm.*` names to the OpenTelemetry GenAI `gen_ai.*` names, and
112
+ renamed the `SpanAttributes` constants with them. Code that only calls `register()` is unaffected; if your code sets or reads
113
+ those attributes directly, see the
114
+ [mapping from the old names](https://github.com/future-agi/traceAI/blob/main/docs/OTEL_GENAI_SEMANTIC_CONVENTIONS.md#migration-from-openinference-llm).
115
+
116
+ ## Features
117
+
118
+ * **Automatic Attribute Mapping**: Converts LiveKit attributes (like `lk.user_input`, `gen_ai.usage.input_tokens`) to Future AGI standard attributes (`input.value`, `llm.tokenCount.prompt`).
119
+ * **Raw Data Preservation**: Keeps original LiveKit attributes and adds `raw.input` / `raw.output` for full debugging context.
120
+ * **Multiprocessing Support**: Designed to work with LiveKit's worker process model.
121
+ * **Event Parsing**: Extracts LLM outputs from `gen_ai.choice` events when available.
122
+
123
+ ## Examples
124
+
125
+ Check the `examples/` directory for complete working agents:
126
+ * `simple_agent.py`: A basic voice assistant agent.
127
+ * `egress_agent.py`: An agent with call recording (Egress) enabled.
128
+
@@ -0,0 +1,112 @@
1
+ # traceAI-livekit
2
+
3
+ OpenTelemetry instrumentation for LiveKit agents integrated with Future AGI.
4
+
5
+ This package provides automatic attribute mapping from LiveKit's native OpenTelemetry instrumentation to Future AGI's semantic conventions, ensuring rich and structured traces in your dashboard.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install traceai-livekit
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ To instrument your LiveKit agent, simply initialize `fi-instrumentation` and enable the attribute mapping **inside your agent's entrypoint**.
16
+
17
+ ```python
18
+ from livekit.agents import AgentServer, JobContext
19
+ from fi_instrumentation.otel import register, ProjectType
20
+ from traceai_livekit import enable_http_attribute_mapping
21
+
22
+ server = AgentServer()
23
+
24
+ @server.rtc_session()
25
+ async def entrypoint(ctx: JobContext):
26
+ # 1. Initialize TraceAI (inside entrypoint for multiprocessing safety)
27
+ register(
28
+ project_name="My LiveKit Agent",
29
+ project_type=ProjectType.OBSERVE,
30
+ set_global_tracer_provider=True,
31
+ )
32
+
33
+ # 2. Enable LiveKit attribute mapping
34
+ # This automatically hooks into LiveKit's telemetry system
35
+ enable_http_attribute_mapping()
36
+
37
+ # ... rest of your agent logic ...
38
+ await ctx.connect()
39
+
40
+ if __name__ == "__main__":
41
+ server.run()
42
+ ```
43
+
44
+ `enable_http_attribute_mapping()` (or `enable_grpc_attribute_mapping()`) hands the provider to LiveKit for you. If you call
45
+ LiveKit's `set_tracer_provider` yourself on the provider `register()` returned, call it after `enable_*`: before it, the call
46
+ drops the Future AGI exporter. LiveKit installs its privacy filter once per provider, so an `allow_pii` passed in a later call is
47
+ ignored. To keep conversation content out of your traces, set `LIVEKIT_TELEMETRY_ALLOW_PII=false` before calling `enable_*`
48
+ (livekit-agents 1.8 and later).
49
+
50
+ ### Flushing at the end of a job
51
+
52
+ On Linux a LiveKit job process ends without running `atexit`, and LiveKit flushes only its own processors, so the spans
53
+ still queued when a job ends, such as the root `agent_session` span, used to never reach Future AGI. `enable_*` now sends
54
+ them twice over:
55
+
56
+ - **When the job shuts down**, if `enable_*` runs inside a job (the usual place, the entrypoint): this sends the voice
57
+ trace, `agent_session` included, as soon as the call ends.
58
+ - **When the process exits**, just before it stops: this sends what LiveKit ends after the shutdown callbacks, its own
59
+ `job_shutdown` span, and the job's root `job_entrypoint` span when tracing is set up before the job starts. With
60
+ set-up inside the entrypoint, LiveKit starts `job_entrypoint` before tracing is on, so it is never recorded and the
61
+ job trace arrives rooted at `job_shutdown`.
62
+
63
+ Each waits at most 3 seconds for the collector, well inside the 10 seconds LiveKit gives a job process to exit, so its own
64
+ clean-up still runs when the collector is slow.
65
+
66
+ If you set tracing up outside a job (for example at import time, in the job process), only the process-exit flush runs.
67
+ To also send each call's trace as soon as the call ends, call `flush_on_job_shutdown()` in each job entrypoint:
68
+
69
+ ```python
70
+ from traceai_livekit import flush_on_job_shutdown
71
+
72
+ @server.rtc_session()
73
+ async def entrypoint(ctx: JobContext):
74
+ flush_on_job_shutdown()
75
+ ...
76
+ ```
77
+
78
+ Do not pass `provider.force_flush` to `ctx.add_shutdown_callback`: LiveKit calls it with the shutdown reason, which
79
+ `force_flush` takes as its timeout, so it fails and nothing is flushed.
80
+
81
+ Limits:
82
+ - A process LiveKit kills (for example when a worker drains and the job overruns its exit time) sends nothing more.
83
+ - Before livekit-agents 1.3, LiveKit closes the session at the same time as the job-shutdown flush, so that flush can miss
84
+ `agent_session`; the process-exit flush still sends it.
85
+ - A second `AgentSession` in one job that is still closing when the shutdown callbacks run is sent by the process-exit
86
+ flush, not the job-shutdown one.
87
+ - In LiveKit Cloud observability mode the session's parent span is recorded on LiveKit's own provider and never reaches
88
+ Future AGI, so the trace has no stored root there.
89
+
90
+ ## Compatibility
91
+
92
+ * **livekit-agents**: 1.x. 1.8 is the tested version (it needs Python 3.10 or later). Since 1.7, LiveKit names its content
93
+ attributes `lk.pii.*` (for example `lk.pii.user_input`); both those names and the earlier ones are mapped to `input.value`
94
+ and `output.value`.
95
+ * **fi-instrumentation-otel**: 1.1.0 or later (1.0.0 fails inside `register()` with OpenTelemetry 1.45, which livekit-agents
96
+ 1.8.7 and later require). 1.0 moved span attributes from the `llm.*` names to the OpenTelemetry GenAI `gen_ai.*` names, and
97
+ renamed the `SpanAttributes` constants with them. Code that only calls `register()` is unaffected; if your code sets or reads
98
+ those attributes directly, see the
99
+ [mapping from the old names](https://github.com/future-agi/traceAI/blob/main/docs/OTEL_GENAI_SEMANTIC_CONVENTIONS.md#migration-from-openinference-llm).
100
+
101
+ ## Features
102
+
103
+ * **Automatic Attribute Mapping**: Converts LiveKit attributes (like `lk.user_input`, `gen_ai.usage.input_tokens`) to Future AGI standard attributes (`input.value`, `llm.tokenCount.prompt`).
104
+ * **Raw Data Preservation**: Keeps original LiveKit attributes and adds `raw.input` / `raw.output` for full debugging context.
105
+ * **Multiprocessing Support**: Designed to work with LiveKit's worker process model.
106
+ * **Event Parsing**: Extracts LLM outputs from `gen_ai.choice` events when available.
107
+
108
+ ## Examples
109
+
110
+ Check the `examples/` directory for complete working agents:
111
+ * `simple_agent.py`: A basic voice assistant agent.
112
+ * `egress_agent.py`: An agent with call recording (Egress) enabled.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "traceAI-livekit"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "OpenTelemetry instrumentation for LiveKit"
5
5
  authors = ["Future AGI <no-reply@futureagi.com>"]
6
6
  readme = "README.md"
@@ -10,13 +10,15 @@ packages = [
10
10
 
11
11
  [tool.poetry.dependencies]
12
12
  python = ">3.9,<3.14"
13
- fi-instrumentation-otel = ">=0.1.14"
13
+ fi-instrumentation-otel = ">=1.1.0"
14
14
 
15
15
  [tool.poetry.group.dev.dependencies]
16
- livekit-agents = "*"
17
- livekit-plugins-openai = "*"
18
- livekit-plugins-silero = "*"
16
+ # livekit-agents 1.8 is the version whose privacy filter the tests cover; it needs Python 3.10+.
17
+ livekit-agents = { version = "~1.8", python = ">=3.10" }
18
+ livekit-plugins-openai = { version = "~1.8", python = ">=3.10" }
19
+ livekit-plugins-silero = { version = "~1.8", python = ">=3.10" }
19
20
  python-dotenv = "*"
21
+ pytest = ">=7.0"
20
22
 
21
23
  [build-system]
22
24
  requires = ["poetry-core"]
@@ -16,9 +16,10 @@ from .integration import (
16
16
  enable_fi_attribute_mapping,
17
17
  enable_grpc_attribute_mapping,
18
18
  enable_http_attribute_mapping,
19
+ flush_on_job_shutdown,
19
20
  )
20
21
 
21
- __version__ = "0.1.0"
22
+ __version__ = "0.1.2"
22
23
 
23
24
  __all__ = [
24
25
  "enable_fi_attribute_mapping",
@@ -26,6 +27,7 @@ __all__ = [
26
27
  "enable_grpc_attribute_mapping",
27
28
  "create_mapped_http_exporter",
28
29
  "create_mapped_grpc_exporter",
30
+ "flush_on_job_shutdown",
29
31
  # Exporters
30
32
  "MappedHTTPSpanExporter",
31
33
  "MappedGRPCSpanExporter",
@@ -6,13 +6,62 @@ LiveKit attributes to Future AGI conventions.
6
6
  """
7
7
 
8
8
  import json
9
- from typing import Any, Dict
9
+ import logging
10
+ import threading
11
+ from typing import Any, Dict, Iterable, Tuple
10
12
 
13
+ from opentelemetry.sdk.trace import ReadableSpan
11
14
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
12
15
 
13
16
  # Import Future AGI span attribute keys
14
17
  from fi_instrumentation.fi_types import SpanAttributes
15
18
 
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # The mapping failure is reported once per process: a broken install fails on every span.
22
+ _mapping_failure_logged = False
23
+ _mapping_failure_lock = threading.Lock()
24
+
25
+
26
+ def _warn_mapping_failed_once(exc: BaseException) -> None:
27
+ """Log, once per process, that the Future AGI mapping failed. Never logs attribute values."""
28
+ global _mapping_failure_logged
29
+ with _mapping_failure_lock:
30
+ if _mapping_failure_logged:
31
+ return
32
+ _mapping_failure_logged = True
33
+ logger.warning(
34
+ "traceai-livekit could not map LiveKit span attributes to Future AGI conventions "
35
+ "(%s); spans are exported without the Future AGI mapping. "
36
+ "Check that fi-instrumentation-otel >= 1.1.0 is installed.",
37
+ type(exc).__name__,
38
+ )
39
+
40
+
41
+ def _first(attributes: Dict[str, Any], keys: Iterable[str]) -> Tuple[bool, Any]:
42
+ """Return (found, value) for the first of `keys` present in `attributes`.
43
+
44
+ LiveKit 1.7 renamed its content attributes (`lk.user_input` -> `lk.pii.user_input`, and so
45
+ on). Callers pass the new name first and the earlier name after it; the new name wins when
46
+ both are present.
47
+ """
48
+ for key in keys:
49
+ if key in attributes:
50
+ return True, attributes[key]
51
+ return False, None
52
+
53
+
54
+ def _has(attributes: Dict[str, Any], keys: Iterable[str]) -> bool:
55
+ return _first(attributes, keys)[0]
56
+
57
+
58
+ def _present(value: Any) -> bool:
59
+ """Whether a content value holds something: not None and not an empty string.
60
+
61
+ `_ensure_json_string("")` would turn an empty value into the JSON text `'""'`.
62
+ """
63
+ return value is not None and value != ""
64
+
16
65
 
17
66
  def _ensure_json_string(value: Any) -> str:
18
67
  """Ensure a value is a valid JSON string."""
@@ -39,6 +88,17 @@ def _detect_mime_type(value: Any) -> str:
39
88
  return "text/plain"
40
89
 
41
90
 
91
+ def _is_empty_function_calls(value: Any) -> bool:
92
+ """Whether a function-calls attribute holds no call: absent, empty, or an empty JSON list."""
93
+ if value is None:
94
+ return True
95
+ if isinstance(value, (list, tuple)):
96
+ return len(value) == 0
97
+ if isinstance(value, str):
98
+ return value.strip() in ("", "[]", "null")
99
+ return False
100
+
101
+
42
102
  def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, Any]:
43
103
  """Map LiveKit attributes to Future AGI conventions."""
44
104
  if not attributes:
@@ -51,56 +111,52 @@ def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, A
51
111
  # Common Keys
52
112
  LK_SPEECH_ID = "lk.speech_id"
53
113
  LK_AGENT_LABEL = "lk.agent_label"
54
- LK_START_TIME = "lk.start_time"
55
- LK_END_TIME = "lk.end_time"
56
- LK_RETRY_COUNT = "lk.retry_count"
57
114
 
58
115
  LK_PARTICIPANT_ID = "lk.participant_id"
59
- LK_PARTICIPANT_IDENTITY = "lk.participant_identity"
116
+ LK_PARTICIPANT_IDENTITY = ("lk.pii.participant_identity", "lk.participant_identity")
60
117
  LK_PARTICIPANT_KIND = "lk.participant_kind"
61
118
 
62
119
  # Session Start
63
120
  LK_JOB_ID = "lk.job_id"
64
121
  LK_AGENT_NAME = "lk.agent_name"
65
- LK_ROOM_NAME = "lk.room_name"
122
+ LK_ROOM_NAME = ("lk.pii.room_name", "lk.room_name")
66
123
  LK_SESSION_OPTIONS = "lk.session_options"
67
124
 
68
125
  # Assistant Turn
69
- LK_USER_INPUT = "lk.user_input"
70
- LK_INSTRUCTIONS = "lk.instructions"
126
+ LK_USER_INPUT = ("lk.pii.user_input", "lk.user_input")
127
+ LK_INSTRUCTIONS = ("lk.pii.instructions", "lk.instructions")
71
128
  LK_SPEECH_INTERRUPTED = "lk.interrupted"
72
129
 
73
130
  # LLM Node
74
- LK_CHAT_CTX = "lk.chat_ctx"
131
+ LK_CHAT_CTX = ("lk.pii.chat_ctx", "lk.chat_ctx")
75
132
  LK_FUNCTION_TOOLS = "lk.function_tools"
76
- LK_RESPONSE_TEXT = "lk.response.text"
77
- LK_RESPONSE_FUNCTION_CALLS = "lk.response.function_calls"
133
+ LK_RESPONSE_TEXT = ("lk.pii.response.text", "lk.response.text")
134
+ LK_RESPONSE_FUNCTION_CALLS = ("lk.pii.response.function_calls", "lk.response.function_calls")
78
135
 
79
136
  # Function Tool
80
137
  LK_FUNCTION_TOOL_NAME = "lk.function_tool.name"
81
- LK_FUNCTION_TOOL_ARGS = "lk.function_tool.arguments"
138
+ LK_FUNCTION_TOOL_ARGS = ("lk.pii.function_tool.arguments", "lk.function_tool.arguments")
82
139
  LK_FUNCTION_TOOL_IS_ERROR = "lk.function_tool.is_error"
83
- LK_FUNCTION_TOOL_OUTPUT = "lk.function_tool.output"
140
+ LK_FUNCTION_TOOL_OUTPUT = ("lk.pii.function_tool.output", "lk.function_tool.output")
84
141
 
85
142
  # TTS Node
86
- LK_TTS_INPUT_TEXT = "lk.input_text"
143
+ LK_TTS_INPUT_TEXT = ("lk.pii.input_text", "lk.input_text")
87
144
  LK_TTS_STREAMING = "lk.tts.streaming"
88
145
  LK_TTS_LABEL = "lk.tts.label"
89
146
 
90
147
  # EOU Detection & Metrics (used for Metadata)
91
148
  LK_EOU_DELAY = "lk.eou.endpointing_delay"
92
149
  LK_EOU_LANGUAGE = "lk.eou.language"
93
- LK_USER_TRANSCRIPT = "lk.user_transcript"
150
+ LK_USER_TRANSCRIPT = ("lk.pii.user_transcript", "lk.user_transcript")
94
151
  LK_TRANSCRIPT_CONFIDENCE = "lk.transcript_confidence"
95
152
  LK_TRANSCRIPTION_DELAY = "lk.transcription_delay"
96
153
  LK_END_OF_TURN_DELAY = "lk.end_of_turn_delay"
97
154
 
98
155
  # Standard OTel GenAI
99
- ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
100
156
  ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
101
157
  ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
102
158
  ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
103
-
159
+
104
160
  # --- MAPPING LOGIC ---
105
161
 
106
162
  # 1. LLM & Model Info
@@ -118,26 +174,29 @@ def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, A
118
174
  # Case A: Assistant Turn (Top-level chain)
119
175
  # Input: User speech/text (LK_USER_INPUT)
120
176
  # Output: often not directly on this span, but might be aggregated or absent
121
- if LK_USER_INPUT in attributes:
122
- input_val = attributes.get(LK_USER_INPUT)
123
- raw_input_val = attributes.get(LK_USER_INPUT)
177
+ found, user_input = _first(attributes, LK_USER_INPUT)
178
+ if found and _present(user_input):
179
+ input_val = user_input
180
+ raw_input_val = user_input
124
181
 
125
182
  # Case B: LLM Node
126
183
  # Input: Chat Context (LK_CHAT_CTX) - usually a list of messages
127
184
  # Output: Generated Text (LK_RESPONSE_TEXT) or Function Calls (LK_RESPONSE_FUNCTION_CALLS)
128
- if LK_CHAT_CTX in attributes:
129
-
130
- chat_ctx = attributes.get(LK_CHAT_CTX)
185
+ found, chat_ctx = _first(attributes, LK_CHAT_CTX)
186
+ if found:
131
187
  input_val = _ensure_json_string(chat_ctx)
132
188
  raw_input_val = chat_ctx
133
189
 
134
- if LK_RESPONSE_TEXT in attributes:
135
- output_val = attributes.get(LK_RESPONSE_TEXT)
190
+ found, response_text = _first(attributes, LK_RESPONSE_TEXT)
191
+ if found and _present(response_text):
192
+ output_val = response_text
136
193
  raw_output_val = output_val
137
-
138
- if LK_RESPONSE_FUNCTION_CALLS in attributes:
194
+
195
+ found, func_calls = _first(attributes, LK_RESPONSE_FUNCTION_CALLS)
196
+ # LiveKit 1.8 sets the function calls on every LLM node, as "[]" when there were none; an empty
197
+ # list is no output, so the reply alone stays the output.
198
+ if found and not _is_empty_function_calls(func_calls):
139
199
  # If there are function calls, this is also an output
140
- func_calls = attributes.get(LK_RESPONSE_FUNCTION_CALLS)
141
200
  if output_val:
142
201
  # If we already have text, append function calls
143
202
  output_val = f"{output_val}\nFunction Calls: {_ensure_json_string(func_calls)}"
@@ -149,21 +208,24 @@ def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, A
149
208
  # Case C: TTS Node
150
209
  # Input: Text to speak (LK_TTS_INPUT_TEXT)
151
210
  # Output: Audio (not usually in attributes, maybe just success)
152
- if LK_TTS_INPUT_TEXT in attributes:
153
- input_val = attributes.get(LK_TTS_INPUT_TEXT)
154
- raw_input_val = attributes.get(LK_TTS_INPUT_TEXT)
211
+ found, tts_input = _first(attributes, LK_TTS_INPUT_TEXT)
212
+ if found and _present(tts_input):
213
+ input_val = tts_input
214
+ raw_input_val = tts_input
155
215
  # TTS output is audio, we don't have the bytes here usually.
156
216
 
157
217
  # Case D: Function Tool Execution
158
218
  # Input: Arguments (LK_FUNCTION_TOOL_ARGS)
159
219
  # Output: Tool Output (LK_FUNCTION_TOOL_OUTPUT)
160
- if LK_FUNCTION_TOOL_ARGS in attributes:
161
- input_val = attributes.get(LK_FUNCTION_TOOL_ARGS)
162
- raw_input_val = attributes.get(LK_FUNCTION_TOOL_ARGS)
163
-
164
- if LK_FUNCTION_TOOL_OUTPUT in attributes:
165
- output_val = attributes.get(LK_FUNCTION_TOOL_OUTPUT)
166
- raw_output_val = attributes.get(LK_FUNCTION_TOOL_OUTPUT)
220
+ found, tool_args = _first(attributes, LK_FUNCTION_TOOL_ARGS)
221
+ if found and _present(tool_args):
222
+ input_val = tool_args
223
+ raw_input_val = tool_args
224
+
225
+ found, tool_output = _first(attributes, LK_FUNCTION_TOOL_OUTPUT)
226
+ if found and _present(tool_output):
227
+ output_val = tool_output
228
+ raw_output_val = tool_output
167
229
 
168
230
  # Case E: Session Start
169
231
  if LK_SESSION_OPTIONS in attributes and raw_input_val is None:
@@ -216,11 +278,13 @@ def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, A
216
278
  mapped[SpanAttributes.GEN_AI_TOOL_DEFINITIONS] = _ensure_json_string(attributes.get(LK_FUNCTION_TOOLS))
217
279
 
218
280
  # 5. Session & User Info
219
- if LK_ROOM_NAME in attributes:
220
- mapped[SpanAttributes.GEN_AI_CONVERSATION_ID] = attributes.get(LK_ROOM_NAME)
221
-
222
- if LK_PARTICIPANT_IDENTITY in attributes:
223
- mapped[SpanAttributes.USER_ID] = attributes.get(LK_PARTICIPANT_IDENTITY)
281
+ found, room_name = _first(attributes, LK_ROOM_NAME)
282
+ if found:
283
+ mapped[SpanAttributes.GEN_AI_CONVERSATION_ID] = room_name
284
+
285
+ found, participant_identity = _first(attributes, LK_PARTICIPANT_IDENTITY)
286
+ if found:
287
+ mapped[SpanAttributes.USER_ID] = participant_identity
224
288
 
225
289
  # 6. Metadata Aggregation
226
290
  metadata: Dict[str, Any] = {}
@@ -237,8 +301,10 @@ def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, A
237
301
  ]
238
302
 
239
303
  for key in metadata_keys:
240
- if key in attributes:
241
- metadata[key] = attributes[key]
304
+ # A renamed key is a (new name, earlier name) tuple; each name present is recorded as is.
305
+ for name in (key if isinstance(key, tuple) else (key,)):
306
+ if name in attributes:
307
+ metadata[name] = attributes[name]
242
308
 
243
309
  # Also include any "metrics" attributes if they exist
244
310
  for key in attributes:
@@ -266,15 +332,15 @@ def _map_attributes_to_fi_conventions(attributes: Dict[str, Any]) -> Dict[str, A
266
332
 
267
333
  if LK_FUNCTION_TOOL_NAME in attributes:
268
334
  span_kind = "TOOL"
269
- elif LK_CHAT_CTX in attributes or ATTR_GEN_AI_REQUEST_MODEL in attributes:
335
+ elif _has(attributes, LK_CHAT_CTX) or ATTR_GEN_AI_REQUEST_MODEL in attributes:
270
336
  span_kind = "LLM"
271
- elif LK_TTS_INPUT_TEXT in attributes:
337
+ elif _has(attributes, LK_TTS_INPUT_TEXT):
272
338
  # TTS is technically model inference, map to LLM for now or generic
273
339
  span_kind = "LLM"
274
- elif LK_USER_INPUT in attributes or LK_SPEECH_ID in attributes:
340
+ elif _has(attributes, LK_USER_INPUT) or LK_SPEECH_ID in attributes:
275
341
  # Assistant Turn -> CHAIN
276
342
  span_kind = "CHAIN"
277
- elif LK_JOB_ID in attributes and LK_ROOM_NAME in attributes and LK_SESSION_OPTIONS in attributes:
343
+ elif LK_JOB_ID in attributes and _has(attributes, LK_ROOM_NAME) and LK_SESSION_OPTIONS in attributes:
278
344
  # Session Start -> AGENT
279
345
  span_kind = "AGENT"
280
346
 
@@ -296,30 +362,48 @@ class BaseMappedSpanExporter(SpanExporter):
296
362
  base = attributes
297
363
  try:
298
364
  return _map_attributes_to_fi_conventions(base)
299
- except Exception:
365
+ except Exception as exc:
366
+ # The span is still exported, without the Future AGI mapping.
367
+ _warn_mapping_failed_once(exc)
300
368
  return base
301
369
 
302
370
  def export(self, spans) -> SpanExportResult:
371
+ # The spans handed in are shared with every other processor on the provider (LiveKit
372
+ # Cloud's exporter among them), and LiveKit redacts by attribute name. The mapping copies
373
+ # conversation content under names with no "pii" segment (input.value, output.value, the
374
+ # metadata JSON, gen_ai.conversation.id, user.id), so it must never be written back onto
375
+ # a span we were handed: it goes on a copy, as LiveKit's own pii.redact() builds one.
376
+ exported = []
303
377
  for span in spans:
304
378
  try:
305
- if (
306
- hasattr(span, "_attributes")
307
- and getattr(span, "_attributes") is not None
308
- ):
309
- original_attributes = getattr(span, "_attributes")
310
-
311
- base_attributes = dict(original_attributes)
312
-
313
- # Include span name in mapping logic if needed, though _convert_attributes currently only takes dict
314
- # We could pass span name to _map_attributes_to_fi_conventions if we refactored,
315
- # but attribute-based detection is usually robust enough for LiveKit.
316
-
317
- mapped_attributes = self._convert_attributes(base_attributes)
318
-
319
- setattr(span, "_attributes", mapped_attributes)
320
-
321
- except Exception:
322
- continue
323
-
324
- return super().export(spans)
325
-
379
+ exported.append(self._mapped_copy(span))
380
+ except Exception as exc:
381
+ # The span is still exported, as it is, without the Future AGI mapping.
382
+ _warn_mapping_failed_once(exc)
383
+ exported.append(span)
384
+
385
+ return super().export(exported)
386
+
387
+ def _mapped_copy(self, span):
388
+ """A copy of `span` carrying the mapped attributes; `span` itself when there is nothing to map."""
389
+ attributes = span.attributes
390
+ if not attributes:
391
+ return span
392
+ original = dict(attributes)
393
+ mapped = self._convert_attributes(dict(original))
394
+ if mapped == original:
395
+ return span
396
+ return ReadableSpan(
397
+ name=span.name,
398
+ context=span.context,
399
+ parent=span.parent,
400
+ resource=span.resource,
401
+ attributes=mapped,
402
+ events=span.events,
403
+ links=span.links,
404
+ kind=span.kind,
405
+ status=span.status,
406
+ start_time=span.start_time,
407
+ end_time=span.end_time,
408
+ instrumentation_scope=span.instrumentation_scope,
409
+ )