unifyapps-otel 0.1.0__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,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 UnifyApps
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: unifyapps-otel
3
+ Version: 0.1.0
4
+ Summary: Lightweight OpenTelemetry wrapper with UnifyApps-aware defaults for tracing LLM/agent applications.
5
+ Author: UnifyApps
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://unifyapps.com
8
+ Keywords: opentelemetry,otel,tracing,llm,observability,unifyapps
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: opentelemetry-api
16
+ Requires-Dist: opentelemetry-sdk
17
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http
18
+ Provides-Extra: openinference
19
+ Requires-Dist: openinference-instrumentation; extra == "openinference"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest<9,>=7.4; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # unifyapps-otel
25
+
26
+ Send your LLM/agent traces to UnifyApps in **one call**. A thin, opinionated
27
+ wrapper around OpenTelemetry that points it at your UnifyApps ingestion
28
+ endpoint and stays out of your way.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install unifyapps-otel
34
+ # plus whatever you want to trace, e.g.:
35
+ pip install openinference-instrumentation-langchain
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from unifyapps.otel import register
42
+
43
+ tracer_provider = register(
44
+ endpoint="https://<your-unifyapps-domain>/api-endpoint/agent-otel-exporter-otlp/<agent-id>",
45
+ api_key="your-unifyapps-api-key", # optional
46
+ auto_instrument=True, # instrument all installed OpenInference libraries
47
+ )
48
+ ```
49
+
50
+ That's the whole configuration. Copy the `endpoint` from the UnifyApps
51
+ onboarding screen for your agent — it already identifies your agent, so there is
52
+ nothing else to wire up.
53
+
54
+ `register(...)` only sets up OpenTelemetry — it does **not** instrument your app
55
+ by itself. Use `auto_instrument=True`, or call a specific instrumentor:
56
+
57
+ ```python
58
+ tracer_provider = register(endpoint="...")
59
+
60
+ from openinference.instrumentation.langchain import LangChainInstrumentor
61
+ LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ `register()` takes only what a user genuinely needs:
67
+
68
+ | Argument | Required | Purpose |
69
+ |---|---|---|
70
+ | `endpoint` | yes* | Your UnifyApps OTLP URL (from onboarding). `/v1/traces` is appended if missing. |
71
+ | `api_key` | no | Auth Token, sent as the `authorization` header. Omit if your endpoint needs no auth. |
72
+ | `auto_instrument` | no | Instrument every installed OpenInference library. Default `False`. |
73
+ | `set_global_tracer_provider` | no | Register as the global OTel provider. Default `True`. |
74
+ | `headers` | no | Extra request headers (advanced — e.g. a non-default auth header). |
75
+ | `span_processors` | no | Processors run before export, to enrich/redact spans (advanced). |
76
+
77
+ \* `endpoint` and `api_key` fall back to environment variables:
78
+
79
+ | Variable | Maps to |
80
+ |---|---|
81
+ | `UNIFYAPPS_COLLECTOR_ENDPOINT` | `endpoint` |
82
+ | `UNIFYAPPS_API_KEY` | `api_key` |
83
+
84
+ ```python
85
+ from unifyapps.otel import register
86
+ tracer_provider = register(auto_instrument=True) # endpoint + key from env
87
+ ```
88
+
89
+ ## One trace = one export
90
+
91
+ This library exports **each trace as exactly one OTLP request.** When a turn's
92
+ root span ends, the complete trace — root and every child — is shipped in a
93
+ single request, on a background thread, so:
94
+
95
+ - the backend always receives a **whole trace** (a turn is never split across
96
+ exports), and
97
+ - your user **never waits** on ingestion (the export happens off the request
98
+ thread).
99
+
100
+ This is the only export model, and there is nothing to configure. Different
101
+ turns ship as separate exports; that is expected.
102
+
103
+ > Short-lived scripts: call `tracer_provider.shutdown()` (or `.force_flush()`)
104
+ > before the process exits so buffered traces are sent. Long-running servers can
105
+ > rely on the global provider's `atexit` flush.
106
+
107
+ ## Sessions & users
108
+
109
+ Session and user identity are **not** computed by this package or derived from
110
+ the span tree. They are flat span attributes — `session.id` and `user.id` —
111
+ stamped on **every** span produced inside a context. This is the standard
112
+ OpenInference model for identity: the *application* supplies the id, and the
113
+ backend groups by it.
114
+
115
+ Wrap a conversation with `using_session(...)`; add `using_user(...)` to tag who
116
+ it belongs to:
117
+
118
+ ```python
119
+ from unifyapps.otel import using_session, using_user
120
+
121
+ with using_user("user-42"), using_session(session_id="conversation-123"):
122
+ agent.invoke(...) # every span here carries user.id + session.id
123
+ ```
124
+
125
+ Or set both at once with `using_attributes(...)`:
126
+
127
+ ```python
128
+ from unifyapps.otel import using_attributes
129
+
130
+ with using_attributes(session_id="conversation-123", user_id="user-42"):
131
+ agent.invoke(...)
132
+ ```
133
+
134
+ Rules of thumb:
135
+
136
+ - Use **one stable `session.id` for the whole multi-turn conversation** — reuse
137
+ it across turns. One `session.id` spans many traces (many turns).
138
+ - `session.id` only groups traces for observability. It does **not** give the
139
+ model memory — feeding prior turns back to the LLM is your app's job.
140
+ - These helpers stamp spans created by OpenInference **instrumentors**. If you
141
+ create spans **by hand** (e.g. wrapping a raw SDK call), set the attributes
142
+ yourself: `span.set_attribute("session.id", session_id)`.
143
+
144
+ ## Scope
145
+
146
+ Intentionally thin. It builds the `TracerProvider`, the OTLP HTTP exporter, and
147
+ the auth headers; guarantees one-export-per-trace; and (optionally) discovers
148
+ installed OpenInference instrumentors. Everything else — how spans are parsed and
149
+ how traces roll up into sessions — is an ingestion-side concern.
@@ -0,0 +1,126 @@
1
+ # unifyapps-otel
2
+
3
+ Send your LLM/agent traces to UnifyApps in **one call**. A thin, opinionated
4
+ wrapper around OpenTelemetry that points it at your UnifyApps ingestion
5
+ endpoint and stays out of your way.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install unifyapps-otel
11
+ # plus whatever you want to trace, e.g.:
12
+ pip install openinference-instrumentation-langchain
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from unifyapps.otel import register
19
+
20
+ tracer_provider = register(
21
+ endpoint="https://<your-unifyapps-domain>/api-endpoint/agent-otel-exporter-otlp/<agent-id>",
22
+ api_key="your-unifyapps-api-key", # optional
23
+ auto_instrument=True, # instrument all installed OpenInference libraries
24
+ )
25
+ ```
26
+
27
+ That's the whole configuration. Copy the `endpoint` from the UnifyApps
28
+ onboarding screen for your agent — it already identifies your agent, so there is
29
+ nothing else to wire up.
30
+
31
+ `register(...)` only sets up OpenTelemetry — it does **not** instrument your app
32
+ by itself. Use `auto_instrument=True`, or call a specific instrumentor:
33
+
34
+ ```python
35
+ tracer_provider = register(endpoint="...")
36
+
37
+ from openinference.instrumentation.langchain import LangChainInstrumentor
38
+ LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ `register()` takes only what a user genuinely needs:
44
+
45
+ | Argument | Required | Purpose |
46
+ |---|---|---|
47
+ | `endpoint` | yes* | Your UnifyApps OTLP URL (from onboarding). `/v1/traces` is appended if missing. |
48
+ | `api_key` | no | Auth Token, sent as the `authorization` header. Omit if your endpoint needs no auth. |
49
+ | `auto_instrument` | no | Instrument every installed OpenInference library. Default `False`. |
50
+ | `set_global_tracer_provider` | no | Register as the global OTel provider. Default `True`. |
51
+ | `headers` | no | Extra request headers (advanced — e.g. a non-default auth header). |
52
+ | `span_processors` | no | Processors run before export, to enrich/redact spans (advanced). |
53
+
54
+ \* `endpoint` and `api_key` fall back to environment variables:
55
+
56
+ | Variable | Maps to |
57
+ |---|---|
58
+ | `UNIFYAPPS_COLLECTOR_ENDPOINT` | `endpoint` |
59
+ | `UNIFYAPPS_API_KEY` | `api_key` |
60
+
61
+ ```python
62
+ from unifyapps.otel import register
63
+ tracer_provider = register(auto_instrument=True) # endpoint + key from env
64
+ ```
65
+
66
+ ## One trace = one export
67
+
68
+ This library exports **each trace as exactly one OTLP request.** When a turn's
69
+ root span ends, the complete trace — root and every child — is shipped in a
70
+ single request, on a background thread, so:
71
+
72
+ - the backend always receives a **whole trace** (a turn is never split across
73
+ exports), and
74
+ - your user **never waits** on ingestion (the export happens off the request
75
+ thread).
76
+
77
+ This is the only export model, and there is nothing to configure. Different
78
+ turns ship as separate exports; that is expected.
79
+
80
+ > Short-lived scripts: call `tracer_provider.shutdown()` (or `.force_flush()`)
81
+ > before the process exits so buffered traces are sent. Long-running servers can
82
+ > rely on the global provider's `atexit` flush.
83
+
84
+ ## Sessions & users
85
+
86
+ Session and user identity are **not** computed by this package or derived from
87
+ the span tree. They are flat span attributes — `session.id` and `user.id` —
88
+ stamped on **every** span produced inside a context. This is the standard
89
+ OpenInference model for identity: the *application* supplies the id, and the
90
+ backend groups by it.
91
+
92
+ Wrap a conversation with `using_session(...)`; add `using_user(...)` to tag who
93
+ it belongs to:
94
+
95
+ ```python
96
+ from unifyapps.otel import using_session, using_user
97
+
98
+ with using_user("user-42"), using_session(session_id="conversation-123"):
99
+ agent.invoke(...) # every span here carries user.id + session.id
100
+ ```
101
+
102
+ Or set both at once with `using_attributes(...)`:
103
+
104
+ ```python
105
+ from unifyapps.otel import using_attributes
106
+
107
+ with using_attributes(session_id="conversation-123", user_id="user-42"):
108
+ agent.invoke(...)
109
+ ```
110
+
111
+ Rules of thumb:
112
+
113
+ - Use **one stable `session.id` for the whole multi-turn conversation** — reuse
114
+ it across turns. One `session.id` spans many traces (many turns).
115
+ - `session.id` only groups traces for observability. It does **not** give the
116
+ model memory — feeding prior turns back to the LLM is your app's job.
117
+ - These helpers stamp spans created by OpenInference **instrumentors**. If you
118
+ create spans **by hand** (e.g. wrapping a raw SDK call), set the attributes
119
+ yourself: `span.set_attribute("session.id", session_id)`.
120
+
121
+ ## Scope
122
+
123
+ Intentionally thin. It builds the `TracerProvider`, the OTLP HTTP exporter, and
124
+ the auth headers; guarantees one-export-per-trace; and (optionally) discovers
125
+ installed OpenInference instrumentors. Everything else — how spans are parsed and
126
+ how traces roll up into sessions — is an ingestion-side concern.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unifyapps-otel"
7
+ dynamic = ["version"]
8
+ description = "Lightweight OpenTelemetry wrapper with UnifyApps-aware defaults for tracing LLM/agent applications."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "UnifyApps" }]
13
+ keywords = ["opentelemetry", "otel", "tracing", "llm", "observability", "unifyapps"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "opentelemetry-api",
21
+ "opentelemetry-sdk",
22
+ "opentelemetry-exporter-otlp-proto-http",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ # Full-featured session helper + auto-instrumentation entry points.
27
+ openinference = ["openinference-instrumentation"]
28
+ dev = ["pytest>=7.4,<9"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://unifyapps.com"
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = { attr = "unifyapps.otel.version.__version__" }
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+ # PEP 420 namespace package: `unifyapps` is a namespace, `unifyapps.otel` is ours.
39
+ namespaces = true
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ from .otel import (
2
+ register,
3
+ using_attributes,
4
+ using_session,
5
+ using_user,
6
+ )
7
+ from .version import __version__
8
+
9
+ __all__ = [
10
+ "register",
11
+ "using_attributes",
12
+ "using_session",
13
+ "using_user",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,304 @@
1
+ """
2
+ unifyapps-otel — configure OpenTelemetry to stream LLM/agent traces to your
3
+ UnifyApps ingestion endpoint in a single ``register(...)`` call.
4
+
5
+ Export model (the only one): **one network export per trace.** When a turn's
6
+ root span ends, the whole trace is shipped as a single OTLP request, on a
7
+ background thread, so the end user never waits on ingestion and the backend
8
+ always receives a complete trace. There is nothing to configure about this.
9
+
10
+ Works with any OpenInference/OpenTelemetry instrumentor — LangChain, LangGraph,
11
+ the OpenAI and Anthropic SDKs, LlamaIndex, CrewAI, LiteLLM, Bedrock, and so on.
12
+ """
13
+
14
+ import logging
15
+ import queue
16
+ import threading
17
+ from importlib.metadata import entry_points
18
+ from typing import Dict, List, Optional
19
+ from urllib.parse import urlparse
20
+
21
+ from opentelemetry import trace as trace_api
22
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
23
+ OTLPSpanExporter as _HTTPSpanExporter,
24
+ )
25
+ from opentelemetry.sdk.resources import Resource
26
+ from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
27
+ from opentelemetry.sdk.trace.export import SpanExporter
28
+
29
+ from .settings import (
30
+ ENV_UNIFYAPPS_API_KEY,
31
+ ENV_UNIFYAPPS_COLLECTOR_ENDPOINT,
32
+ get_env_api_key,
33
+ get_env_collector_endpoint,
34
+ )
35
+ from .version import __version__
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Header the API key rides in, and the OTLP HTTP traces path.
40
+ _API_KEY_HEADER = "authorization"
41
+ _INTERFACE_HEADER = "unifyapps-interface"
42
+ _TRACES_PATH = "/v1/traces"
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Session / user convenience re-exports
47
+ # ---------------------------------------------------------------------------
48
+ # Session and user identity are NOT computed by this package. They are flat span
49
+ # attributes — `session.id` and `user.id` — stamped on EVERY span produced
50
+ # inside the context. We re-export the exact OpenInference helpers so your app
51
+ # has one obvious import path:
52
+ #
53
+ # from unifyapps.otel import using_session, using_user, using_attributes
54
+ #
55
+ # `openinference-instrumentation` ships with every `openinference-instrumentation-*`
56
+ # library, so installing any instrumentor gives you the full-featured helpers. If
57
+ # it is somehow absent we fall back to tiny baggage-based shims with the same keys.
58
+ try:
59
+ from openinference.instrumentation import ( # noqa: F401
60
+ using_attributes,
61
+ using_session,
62
+ using_user,
63
+ )
64
+ except Exception: # pragma: no cover - openinference is an optional convenience
65
+
66
+ from contextlib import contextmanager
67
+ from opentelemetry import context as _context_api
68
+ from opentelemetry.baggage import set_baggage
69
+
70
+ @contextmanager
71
+ def using_session(session_id: str):
72
+ """Fallback: attach ``session.id`` to the active context as baggage."""
73
+ token = _context_api.attach(set_baggage("session.id", session_id))
74
+ try:
75
+ yield
76
+ finally:
77
+ _context_api.detach(token)
78
+
79
+ @contextmanager
80
+ def using_user(user_id: str):
81
+ """Fallback: attach ``user.id`` to the active context as baggage."""
82
+ token = _context_api.attach(set_baggage("user.id", user_id))
83
+ try:
84
+ yield
85
+ finally:
86
+ _context_api.detach(token)
87
+
88
+ @contextmanager
89
+ def using_attributes(session_id=None, user_id=None):
90
+ """Fallback: attach ``session.id`` and/or ``user.id`` as baggage."""
91
+ ctx = _context_api.get_current()
92
+ if session_id is not None:
93
+ ctx = set_baggage("session.id", session_id, context=ctx)
94
+ if user_id is not None:
95
+ ctx = set_baggage("user.id", user_id, context=ctx)
96
+ token = _context_api.attach(ctx)
97
+ try:
98
+ yield
99
+ finally:
100
+ _context_api.detach(token)
101
+
102
+
103
+ class _CompleteTraceSpanProcessor(SpanProcessor):
104
+ """Export each trace as exactly one OTLP request, asynchronously.
105
+
106
+ Finished spans are buffered by trace id. A trace is complete when its root
107
+ span (``span.parent is None``) ends — in OpenTelemetry the root ends *after*
108
+ all of its children, so at that moment the whole trace is in hand. The
109
+ complete set is handed to a background worker for a single ``export`` call.
110
+
111
+ Guarantees:
112
+ * one export carries every span of one trace — a turn is never split;
113
+ * each span is exported exactly once — no duplicates, no gaps;
114
+ * the caller's thread never blocks on the network (O(1) hand-off);
115
+ * a failed export can never kill the worker or the caller;
116
+ * ``force_flush``/``shutdown`` drain the queue and are idempotent.
117
+
118
+ Spans that arrive after ``shutdown`` are dropped (the provider is gone).
119
+ Buffered spans whose root never ends are flushed best-effort on shutdown.
120
+ """
121
+
122
+ def __init__(self, span_exporter: SpanExporter) -> None:
123
+ self._exporter = span_exporter
124
+ self._pending: Dict[int, List] = {}
125
+ self._lock = threading.Lock()
126
+ self._queue: "queue.Queue" = queue.Queue()
127
+ self._stopped = threading.Event()
128
+ self._shutdown_lock = threading.Lock()
129
+ self._worker = threading.Thread(
130
+ target=self._run, name="UnifyAppsTraceExporter", daemon=True
131
+ )
132
+ self._worker.start()
133
+
134
+ def on_start(self, span, parent_context=None) -> None:
135
+ pass
136
+
137
+ def on_end(self, span) -> None:
138
+ if self._stopped.is_set():
139
+ return # provider is shutting down; do not resurrect the worker
140
+ trace_id = span.context.trace_id
141
+ is_root = span.parent is None
142
+ with self._lock:
143
+ self._pending.setdefault(trace_id, []).append(span)
144
+ batch = self._pending.pop(trace_id) if is_root else None
145
+ if batch:
146
+ # hand off the COMPLETE trace; never export on the caller's thread
147
+ self._queue.put(tuple(batch))
148
+
149
+ def _run(self) -> None:
150
+ while True:
151
+ batch = self._queue.get()
152
+ try:
153
+ if batch is None: # shutdown sentinel
154
+ return
155
+ self._exporter.export(batch)
156
+ except Exception: # never let a bad export kill the worker
157
+ logger.exception("UnifyApps trace export failed")
158
+ finally:
159
+ self._queue.task_done()
160
+
161
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
162
+ # Block until the worker has drained everything queued so far. The OTLP
163
+ # exporter has its own request timeout, so this cannot hang forever.
164
+ self._queue.join()
165
+ return True
166
+
167
+ def shutdown(self) -> None:
168
+ with self._shutdown_lock:
169
+ if self._stopped.is_set():
170
+ return # idempotent
171
+ self._stopped.set()
172
+ # flush traces whose root never arrived (best effort), then stop
173
+ with self._lock:
174
+ leftovers = [b for b in self._pending.values() if b]
175
+ self._pending.clear()
176
+ for batch in leftovers:
177
+ self._queue.put(tuple(batch))
178
+ self._queue.join()
179
+ self._queue.put(None) # tell the worker to exit
180
+ self._worker.join(timeout=30)
181
+ self._exporter.shutdown()
182
+
183
+
184
+ def register(
185
+ *,
186
+ endpoint: Optional[str] = None,
187
+ api_key: Optional[str] = None,
188
+ headers: Optional[Dict[str, str]] = None,
189
+ auto_instrument: bool = False,
190
+ set_global_tracer_provider: bool = True,
191
+ span_processors: Optional[List[SpanProcessor]] = None,
192
+ ) -> TracerProvider:
193
+ """Configure OpenTelemetry tracing for UnifyApps and return the provider.
194
+
195
+ This call only wires up the exporter; instrument your app either by passing
196
+ ``auto_instrument=True`` (discovers every installed OpenInference
197
+ instrumentor) or by calling a specific instrumentor's
198
+ ``instrument(tracer_provider=...)`` afterwards.
199
+
200
+ Args:
201
+ endpoint: Your UnifyApps OTLP endpoint URL — copy it from the UnifyApps
202
+ onboarding screen for your agent (it already includes the agent id).
203
+ ``/v1/traces`` is appended automatically if absent. Falls back to the
204
+ ``UNIFYAPPS_COLLECTOR_ENDPOINT`` environment variable.
205
+ api_key: Optional API key (the Auth Token from your access profile), sent
206
+ in the ``authorization`` header. Falls back to ``UNIFYAPPS_API_KEY``.
207
+ Omit it if your endpoint needs no auth. For a non-default auth header,
208
+ pass it in ``headers`` and leave ``api_key`` unset.
209
+ headers: Extra headers merged into every export request (advanced).
210
+ auto_instrument: Instrument every installed OpenInference library.
211
+ set_global_tracer_provider: Register as the global OpenTelemetry provider.
212
+ span_processors: Optional processors run before export, to enrich or
213
+ redact spans in place (advanced).
214
+
215
+ Returns:
216
+ The configured ``TracerProvider``. For a short-lived process, call
217
+ ``.shutdown()`` (or ``.force_flush()``) before exit so buffered traces
218
+ are sent.
219
+ """
220
+ endpoint = _resolve_endpoint(endpoint or get_env_collector_endpoint())
221
+ if api_key is None:
222
+ api_key = get_env_api_key()
223
+ agent_id = _agent_id_from_endpoint(endpoint)
224
+
225
+ resource = Resource.create(
226
+ {
227
+ "service.name": agent_id or "unifyapps-agent",
228
+ "unifyapps.agent.id": agent_id or "",
229
+ }
230
+ )
231
+ tracer_provider = TracerProvider(resource=resource)
232
+
233
+ exporter = _build_exporter(endpoint, api_key, headers)
234
+ for custom in span_processors or []:
235
+ tracer_provider.add_span_processor(custom)
236
+ tracer_provider.add_span_processor(_CompleteTraceSpanProcessor(exporter))
237
+
238
+ if set_global_tracer_provider:
239
+ trace_api.set_tracer_provider(tracer_provider)
240
+
241
+ if auto_instrument:
242
+ _auto_instrument_installed_openinference_libraries(tracer_provider)
243
+
244
+ logger.info("unifyapps-otel configured, exporting traces to %s", endpoint)
245
+ return tracer_provider
246
+
247
+
248
+ def _resolve_endpoint(endpoint: Optional[str]) -> str:
249
+ """Validate the endpoint and ensure it ends with the OTLP traces path."""
250
+ if not endpoint:
251
+ raise ValueError(
252
+ "No endpoint. Pass endpoint=... (the OTLP URL shown in your UnifyApps "
253
+ f"onboarding) or set the {ENV_UNIFYAPPS_COLLECTOR_ENDPOINT} "
254
+ "environment variable."
255
+ )
256
+ endpoint = endpoint.rstrip("/")
257
+ parsed = urlparse(endpoint)
258
+ if not (parsed.scheme and parsed.netloc):
259
+ raise ValueError(f"Invalid endpoint: {endpoint!r}. Provide a full URL.")
260
+ if not endpoint.endswith(_TRACES_PATH):
261
+ endpoint = f"{endpoint}{_TRACES_PATH}"
262
+ return endpoint
263
+
264
+
265
+ def _agent_id_from_endpoint(endpoint: str) -> Optional[str]:
266
+ """The agent id is the last path segment before ``/v1/traces``."""
267
+ path = urlparse(endpoint).path
268
+ if path.endswith(_TRACES_PATH):
269
+ path = path[: -len(_TRACES_PATH)]
270
+ segments = [s for s in path.split("/") if s]
271
+ return segments[-1] if segments else None
272
+
273
+
274
+ def _build_exporter(
275
+ endpoint: str, api_key: Optional[str], headers: Optional[Dict[str, str]]
276
+ ) -> SpanExporter:
277
+ merged: Dict[str, str] = {_INTERFACE_HEADER: f"unifyapps-otel/{__version__}"}
278
+ if api_key:
279
+ merged[_API_KEY_HEADER] = api_key
280
+ # User-supplied headers win (custom auth scheme, extra routing).
281
+ for k, v in (headers or {}).items():
282
+ merged[k.lower()] = v
283
+ return _HTTPSpanExporter(endpoint=endpoint, headers=merged)
284
+
285
+
286
+ def _auto_instrument_installed_openinference_libraries(
287
+ tracer_provider: TracerProvider,
288
+ ) -> None:
289
+ discovered = entry_points()
290
+ if hasattr(discovered, "select"):
291
+ eps = discovered.select(group="openinference_instrumentor")
292
+ else: # pragma: no cover - older importlib.metadata
293
+ eps = discovered.get("openinference_instrumentor", [])
294
+
295
+ if not eps:
296
+ logger.warning(
297
+ "No OpenInference instrumentors found. Install e.g. "
298
+ "openinference-instrumentation-langchain. Skipping auto-instrumentation."
299
+ )
300
+ return
301
+
302
+ for ep in eps:
303
+ instrumentor_class = ep.load()
304
+ instrumentor_class().instrument(tracer_provider=tracer_provider)
@@ -0,0 +1,17 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ # Environment variables for the UnifyApps OTel wrapper. They mirror the two
5
+ # `register(...)` arguments so the package can be configured entirely from the
6
+ # environment (handy for containers / CI). They are read when `register()` is
7
+ # called — never at import time — so setting them before the call always works.
8
+ ENV_UNIFYAPPS_API_KEY = "UNIFYAPPS_API_KEY"
9
+ ENV_UNIFYAPPS_COLLECTOR_ENDPOINT = "UNIFYAPPS_COLLECTOR_ENDPOINT"
10
+
11
+
12
+ def get_env_api_key() -> Optional[str]:
13
+ return os.getenv(ENV_UNIFYAPPS_API_KEY)
14
+
15
+
16
+ def get_env_collector_endpoint() -> Optional[str]:
17
+ return os.getenv(ENV_UNIFYAPPS_COLLECTOR_ENDPOINT)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: unifyapps-otel
3
+ Version: 0.1.0
4
+ Summary: Lightweight OpenTelemetry wrapper with UnifyApps-aware defaults for tracing LLM/agent applications.
5
+ Author: UnifyApps
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://unifyapps.com
8
+ Keywords: opentelemetry,otel,tracing,llm,observability,unifyapps
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: opentelemetry-api
16
+ Requires-Dist: opentelemetry-sdk
17
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http
18
+ Provides-Extra: openinference
19
+ Requires-Dist: openinference-instrumentation; extra == "openinference"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest<9,>=7.4; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # unifyapps-otel
25
+
26
+ Send your LLM/agent traces to UnifyApps in **one call**. A thin, opinionated
27
+ wrapper around OpenTelemetry that points it at your UnifyApps ingestion
28
+ endpoint and stays out of your way.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install unifyapps-otel
34
+ # plus whatever you want to trace, e.g.:
35
+ pip install openinference-instrumentation-langchain
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from unifyapps.otel import register
42
+
43
+ tracer_provider = register(
44
+ endpoint="https://<your-unifyapps-domain>/api-endpoint/agent-otel-exporter-otlp/<agent-id>",
45
+ api_key="your-unifyapps-api-key", # optional
46
+ auto_instrument=True, # instrument all installed OpenInference libraries
47
+ )
48
+ ```
49
+
50
+ That's the whole configuration. Copy the `endpoint` from the UnifyApps
51
+ onboarding screen for your agent — it already identifies your agent, so there is
52
+ nothing else to wire up.
53
+
54
+ `register(...)` only sets up OpenTelemetry — it does **not** instrument your app
55
+ by itself. Use `auto_instrument=True`, or call a specific instrumentor:
56
+
57
+ ```python
58
+ tracer_provider = register(endpoint="...")
59
+
60
+ from openinference.instrumentation.langchain import LangChainInstrumentor
61
+ LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ `register()` takes only what a user genuinely needs:
67
+
68
+ | Argument | Required | Purpose |
69
+ |---|---|---|
70
+ | `endpoint` | yes* | Your UnifyApps OTLP URL (from onboarding). `/v1/traces` is appended if missing. |
71
+ | `api_key` | no | Auth Token, sent as the `authorization` header. Omit if your endpoint needs no auth. |
72
+ | `auto_instrument` | no | Instrument every installed OpenInference library. Default `False`. |
73
+ | `set_global_tracer_provider` | no | Register as the global OTel provider. Default `True`. |
74
+ | `headers` | no | Extra request headers (advanced — e.g. a non-default auth header). |
75
+ | `span_processors` | no | Processors run before export, to enrich/redact spans (advanced). |
76
+
77
+ \* `endpoint` and `api_key` fall back to environment variables:
78
+
79
+ | Variable | Maps to |
80
+ |---|---|
81
+ | `UNIFYAPPS_COLLECTOR_ENDPOINT` | `endpoint` |
82
+ | `UNIFYAPPS_API_KEY` | `api_key` |
83
+
84
+ ```python
85
+ from unifyapps.otel import register
86
+ tracer_provider = register(auto_instrument=True) # endpoint + key from env
87
+ ```
88
+
89
+ ## One trace = one export
90
+
91
+ This library exports **each trace as exactly one OTLP request.** When a turn's
92
+ root span ends, the complete trace — root and every child — is shipped in a
93
+ single request, on a background thread, so:
94
+
95
+ - the backend always receives a **whole trace** (a turn is never split across
96
+ exports), and
97
+ - your user **never waits** on ingestion (the export happens off the request
98
+ thread).
99
+
100
+ This is the only export model, and there is nothing to configure. Different
101
+ turns ship as separate exports; that is expected.
102
+
103
+ > Short-lived scripts: call `tracer_provider.shutdown()` (or `.force_flush()`)
104
+ > before the process exits so buffered traces are sent. Long-running servers can
105
+ > rely on the global provider's `atexit` flush.
106
+
107
+ ## Sessions & users
108
+
109
+ Session and user identity are **not** computed by this package or derived from
110
+ the span tree. They are flat span attributes — `session.id` and `user.id` —
111
+ stamped on **every** span produced inside a context. This is the standard
112
+ OpenInference model for identity: the *application* supplies the id, and the
113
+ backend groups by it.
114
+
115
+ Wrap a conversation with `using_session(...)`; add `using_user(...)` to tag who
116
+ it belongs to:
117
+
118
+ ```python
119
+ from unifyapps.otel import using_session, using_user
120
+
121
+ with using_user("user-42"), using_session(session_id="conversation-123"):
122
+ agent.invoke(...) # every span here carries user.id + session.id
123
+ ```
124
+
125
+ Or set both at once with `using_attributes(...)`:
126
+
127
+ ```python
128
+ from unifyapps.otel import using_attributes
129
+
130
+ with using_attributes(session_id="conversation-123", user_id="user-42"):
131
+ agent.invoke(...)
132
+ ```
133
+
134
+ Rules of thumb:
135
+
136
+ - Use **one stable `session.id` for the whole multi-turn conversation** — reuse
137
+ it across turns. One `session.id` spans many traces (many turns).
138
+ - `session.id` only groups traces for observability. It does **not** give the
139
+ model memory — feeding prior turns back to the LLM is your app's job.
140
+ - These helpers stamp spans created by OpenInference **instrumentors**. If you
141
+ create spans **by hand** (e.g. wrapping a raw SDK call), set the attributes
142
+ yourself: `span.set_attribute("session.id", session_id)`.
143
+
144
+ ## Scope
145
+
146
+ Intentionally thin. It builds the `TracerProvider`, the OTLP HTTP exporter, and
147
+ the auth headers; guarantees one-export-per-trace; and (optionally) discovers
148
+ installed OpenInference instrumentors. Everything else — how spans are parsed and
149
+ how traces roll up into sessions — is an ingestion-side concern.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/unifyapps/otel/__init__.py
5
+ src/unifyapps/otel/otel.py
6
+ src/unifyapps/otel/settings.py
7
+ src/unifyapps/otel/version.py
8
+ src/unifyapps_otel.egg-info/PKG-INFO
9
+ src/unifyapps_otel.egg-info/SOURCES.txt
10
+ src/unifyapps_otel.egg-info/dependency_links.txt
11
+ src/unifyapps_otel.egg-info/requires.txt
12
+ src/unifyapps_otel.egg-info/top_level.txt
13
+ tests/test_otel.py
@@ -0,0 +1,9 @@
1
+ opentelemetry-api
2
+ opentelemetry-sdk
3
+ opentelemetry-exporter-otlp-proto-http
4
+
5
+ [dev]
6
+ pytest<9,>=7.4
7
+
8
+ [openinference]
9
+ openinference-instrumentation
@@ -0,0 +1,249 @@
1
+ from typing import Any, List, Optional
2
+ from unittest.mock import Mock
3
+
4
+ import pytest
5
+ from opentelemetry.context import Context
6
+ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
7
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
8
+ from opentelemetry.trace import Span
9
+
10
+ from unifyapps.otel.otel import (
11
+ _CompleteTraceSpanProcessor,
12
+ _agent_id_from_endpoint,
13
+ _auto_instrument_installed_openinference_libraries,
14
+ _build_exporter,
15
+ _resolve_endpoint,
16
+ register,
17
+ )
18
+
19
+
20
+ class NoopSpanProcessor(SpanProcessor):
21
+ def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
22
+ pass
23
+
24
+ def on_end(self, span: ReadableSpan) -> None:
25
+ pass
26
+
27
+ def shutdown(self) -> None:
28
+ pass
29
+
30
+ def force_flush(self, timeout_millis: Optional[int] = None) -> bool:
31
+ return True
32
+
33
+
34
+ # --- endpoint resolution ---------------------------------------------------
35
+
36
+ def test_resolve_endpoint_appends_traces_path():
37
+ assert _resolve_endpoint("https://host/otlp/e_1") == "https://host/otlp/e_1/v1/traces"
38
+
39
+
40
+ def test_resolve_endpoint_leaves_existing_traces_path():
41
+ assert _resolve_endpoint("https://host/otlp/e_1/v1/traces") == (
42
+ "https://host/otlp/e_1/v1/traces"
43
+ )
44
+
45
+
46
+ def test_resolve_endpoint_strips_trailing_slash():
47
+ assert _resolve_endpoint("https://host/otlp/e_1/") == "https://host/otlp/e_1/v1/traces"
48
+
49
+
50
+ def test_resolve_endpoint_requires_endpoint():
51
+ with pytest.raises(ValueError):
52
+ _resolve_endpoint(None)
53
+
54
+
55
+ def test_resolve_endpoint_rejects_non_url():
56
+ with pytest.raises(ValueError):
57
+ _resolve_endpoint("not-a-url")
58
+
59
+
60
+ # --- agent id extraction ---------------------------------------------------
61
+
62
+ def test_agent_id_extracted_from_endpoint():
63
+ ep = "https://orbit.uat.unifyapps.com/api-endpoint/agent-otel-exporter-otlp/e_abc/v1/traces"
64
+ assert _agent_id_from_endpoint(ep) == "e_abc"
65
+
66
+
67
+ def test_agent_id_extracted_without_traces_path():
68
+ assert _agent_id_from_endpoint("https://host/x/e_9") == "e_9"
69
+
70
+
71
+ # --- exporter headers ------------------------------------------------------
72
+
73
+ def test_exporter_sends_key_in_authorization_header():
74
+ exporter = _build_exporter("https://h/v1/traces", "tok-1", None)
75
+ assert exporter._session.headers["authorization"] == "tok-1"
76
+ assert exporter._session.headers["unifyapps-interface"].startswith("unifyapps-otel/")
77
+
78
+
79
+ def test_exporter_omits_auth_header_when_no_key():
80
+ exporter = _build_exporter("https://h/v1/traces", None, None)
81
+ assert "authorization" not in exporter._session.headers
82
+
83
+
84
+ def test_exporter_custom_headers_win():
85
+ exporter = _build_exporter("https://h/v1/traces", None, {"api-token": "tok-2"})
86
+ assert exporter._session.headers["api-token"] == "tok-2"
87
+
88
+
89
+ # --- register wiring -------------------------------------------------------
90
+
91
+ def test_register_returns_provider():
92
+ provider = register(
93
+ endpoint="https://host/otlp/e_abc",
94
+ api_key="k",
95
+ set_global_tracer_provider=False,
96
+ )
97
+ assert provider is not None
98
+ provider.shutdown()
99
+
100
+
101
+ def test_register_works_without_api_key():
102
+ provider = register(
103
+ endpoint="https://host/otlp/e_abc",
104
+ set_global_tracer_provider=False,
105
+ )
106
+ provider.shutdown()
107
+
108
+
109
+ def test_register_reads_endpoint_from_env(monkeypatch):
110
+ monkeypatch.setenv("UNIFYAPPS_COLLECTOR_ENDPOINT", "https://host/otlp/e_env")
111
+ provider = register(set_global_tracer_provider=False)
112
+ provider.shutdown()
113
+
114
+
115
+ def test_register_requires_endpoint(monkeypatch):
116
+ monkeypatch.delenv("UNIFYAPPS_COLLECTOR_ENDPOINT", raising=False)
117
+ with pytest.raises(ValueError):
118
+ register(set_global_tracer_provider=False)
119
+
120
+
121
+ def test_register_runs_custom_span_processors():
122
+ proc = NoopSpanProcessor()
123
+ provider = register(
124
+ endpoint="https://host/otlp/e_abc",
125
+ set_global_tracer_provider=False,
126
+ span_processors=[proc],
127
+ )
128
+ provider.shutdown()
129
+
130
+
131
+ # --- atomic-trace processor ------------------------------------------------
132
+
133
+ class _FakeCtx:
134
+ def __init__(self, tid: int) -> None:
135
+ self.trace_id = tid
136
+
137
+
138
+ class _FakeSpan:
139
+ def __init__(self, tid: int, parent: object) -> None:
140
+ self.context = _FakeCtx(tid)
141
+ self.parent = parent
142
+
143
+
144
+ class _CollectingExporter(SpanExporter):
145
+ def __init__(self) -> None:
146
+ self.batches: List[tuple] = []
147
+ self.shutdown_calls = 0
148
+
149
+ def export(self, spans) -> SpanExportResult:
150
+ self.batches.append(tuple(spans))
151
+ return SpanExportResult.SUCCESS
152
+
153
+ def shutdown(self) -> None:
154
+ self.shutdown_calls += 1
155
+
156
+
157
+ def test_atomic_processor_exports_one_complete_trace_per_root():
158
+ exp = _CollectingExporter()
159
+ proc = _CompleteTraceSpanProcessor(exp)
160
+
161
+ # two children arrive first (buffered, nothing exported yet), then the root
162
+ proc.on_end(_FakeSpan(1, parent=object()))
163
+ proc.on_end(_FakeSpan(1, parent=object()))
164
+ assert exp.batches == [] # incomplete trace is NOT exported
165
+
166
+ proc.on_end(_FakeSpan(1, parent=None)) # root ends -> trace complete
167
+ proc.force_flush()
168
+
169
+ assert len(exp.batches) == 1 # exactly one export
170
+ assert len(exp.batches[0]) == 3 # all three spans shipped together
171
+ proc.shutdown()
172
+
173
+
174
+ def test_atomic_processor_keeps_traces_separate():
175
+ exp = _CollectingExporter()
176
+ proc = _CompleteTraceSpanProcessor(exp)
177
+ proc.on_end(_FakeSpan(1, parent=object()))
178
+ proc.on_end(_FakeSpan(2, parent=object()))
179
+ proc.on_end(_FakeSpan(1, parent=None)) # trace 1 completes
180
+ proc.on_end(_FakeSpan(2, parent=None)) # trace 2 completes
181
+ proc.force_flush()
182
+ assert len(exp.batches) == 2 # one export per trace
183
+ proc.shutdown()
184
+
185
+
186
+ def test_atomic_processor_flushes_rootless_leftovers_on_shutdown():
187
+ exp = _CollectingExporter()
188
+ proc = _CompleteTraceSpanProcessor(exp)
189
+ proc.on_end(_FakeSpan(1, parent=object())) # child only; root never ends
190
+ proc.shutdown()
191
+ assert len(exp.batches) == 1 # best-effort flush on shutdown
192
+ assert len(exp.batches[0]) == 1
193
+
194
+
195
+ def test_atomic_processor_shutdown_is_idempotent():
196
+ exp = _CollectingExporter()
197
+ proc = _CompleteTraceSpanProcessor(exp)
198
+ proc.on_end(_FakeSpan(1, parent=None))
199
+ proc.shutdown()
200
+ proc.shutdown() # second call must be a no-op
201
+ assert exp.shutdown_calls == 1
202
+
203
+
204
+ def test_atomic_processor_drops_spans_after_shutdown():
205
+ exp = _CollectingExporter()
206
+ proc = _CompleteTraceSpanProcessor(exp)
207
+ proc.shutdown()
208
+ proc.on_end(_FakeSpan(1, parent=None)) # arrives too late -> dropped
209
+ assert exp.batches == []
210
+
211
+
212
+ # --- auto-instrumentation discovery ---------------------------------------
213
+
214
+ class _FakeEntryPoint:
215
+ def __init__(self, cls: Any) -> None:
216
+ self._cls = cls
217
+
218
+ def load(self) -> Any:
219
+ return self._cls
220
+
221
+
222
+ class _SelectableEntryPoints:
223
+ def __init__(self, eps: List[_FakeEntryPoint]) -> None:
224
+ self._eps = eps
225
+
226
+ def select(self, *, group: str) -> List[_FakeEntryPoint]:
227
+ return self._eps if group == "openinference_instrumentor" else []
228
+
229
+
230
+ class _RecordingInstrumentor:
231
+ instances: List["_RecordingInstrumentor"] = []
232
+
233
+ def __init__(self) -> None:
234
+ self.instrument = Mock()
235
+ self.__class__.instances.append(self)
236
+
237
+
238
+ def test_auto_instrument_invokes_each_discovered_instrumentor(monkeypatch):
239
+ _RecordingInstrumentor.instances = []
240
+ eps = _SelectableEntryPoints([_FakeEntryPoint(_RecordingInstrumentor)])
241
+ monkeypatch.setattr("unifyapps.otel.otel.entry_points", lambda: eps)
242
+
243
+ provider = object()
244
+ _auto_instrument_installed_openinference_libraries(provider)
245
+
246
+ assert len(_RecordingInstrumentor.instances) == 1
247
+ _RecordingInstrumentor.instances[0].instrument.assert_called_once_with(
248
+ tracer_provider=provider
249
+ )