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.
- traceai_livekit-0.1.2/PKG-INFO +128 -0
- traceai_livekit-0.1.2/README.md +112 -0
- {traceai_livekit-0.1.1 → traceai_livekit-0.1.2}/pyproject.toml +7 -5
- {traceai_livekit-0.1.1 → traceai_livekit-0.1.2}/traceai_livekit/__init__.py +3 -1
- {traceai_livekit-0.1.1 → traceai_livekit-0.1.2}/traceai_livekit/exporters/base_exporter.py +155 -71
- traceai_livekit-0.1.2/traceai_livekit/integration.py +433 -0
- traceai_livekit-0.1.1/PKG-INFO +0 -71
- traceai_livekit-0.1.1/README.md +0 -55
- traceai_livekit-0.1.1/traceai_livekit/integration.py +0 -198
- {traceai_livekit-0.1.1 → traceai_livekit-0.1.2}/traceai_livekit/exporters/__init__.py +0 -0
- {traceai_livekit-0.1.1 → traceai_livekit-0.1.2}/traceai_livekit/exporters/grpc_exporter.py +0 -0
- {traceai_livekit-0.1.1 → traceai_livekit-0.1.2}/traceai_livekit/exporters/http_exporter.py +0 -0
|
@@ -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.
|
|
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 = ">=
|
|
13
|
+
fi-instrumentation-otel = ">=1.1.0"
|
|
14
14
|
|
|
15
15
|
[tool.poetry.group.dev.dependencies]
|
|
16
|
-
livekit-agents
|
|
17
|
-
livekit-
|
|
18
|
-
livekit-plugins-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
)
|