forgesight-langfuse 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.
- forgesight_langfuse-0.1.0/.gitignore +38 -0
- forgesight_langfuse-0.1.0/PKG-INFO +62 -0
- forgesight_langfuse-0.1.0/README.md +36 -0
- forgesight_langfuse-0.1.0/pyproject.toml +42 -0
- forgesight_langfuse-0.1.0/src/forgesight_langfuse/__init__.py +9 -0
- forgesight_langfuse-0.1.0/src/forgesight_langfuse/exporter.py +105 -0
- forgesight_langfuse-0.1.0/src/forgesight_langfuse/py.typed +0 -0
- forgesight_langfuse-0.1.0/tests/test_exporter.py +107 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# venv / tooling
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.uv/
|
|
14
|
+
uv.lock
|
|
15
|
+
|
|
16
|
+
# test / type / lint caches
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
coverage.xml
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# secrets / local env (never commit)
|
|
26
|
+
.env
|
|
27
|
+
.env.*
|
|
28
|
+
|
|
29
|
+
# editor / OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
.idea/
|
|
32
|
+
.vscode/
|
|
33
|
+
|
|
34
|
+
# local-only session working state (per the workspace pipeline)
|
|
35
|
+
.claude/state/
|
|
36
|
+
|
|
37
|
+
# local-only launch planning (not part of the published repo)
|
|
38
|
+
/launch/
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgesight-langfuse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ForgeSight Langfuse exporter — OTLP ingest with native langfuse.* observation mapping.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
|
|
6
|
+
Project-URL: Repository, https://github.com/Scaffoldic/forgesight
|
|
7
|
+
Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
|
|
9
|
+
Author: kjoshi
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai-agents,forgesight,langfuse,llm,observability,otlp
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: forgesight-core
|
|
24
|
+
Requires-Dist: forgesight-otel
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# forgesight-langfuse
|
|
28
|
+
|
|
29
|
+
The Langfuse exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
|
|
30
|
+
Ships ForgeSight records to Langfuse over its OTLP ingest endpoint
|
|
31
|
+
(`/api/public/otel`, Basic auth), enriched with the native **`langfuse.*`**
|
|
32
|
+
attributes so LLM calls render as **generation** observations, tools as **tool**
|
|
33
|
+
observations, and the run's `user`/`session`/`tags` lift to the trace.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install forgesight-langfuse
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import forgesight
|
|
41
|
+
from forgesight_langfuse import LangfuseExporter
|
|
42
|
+
|
|
43
|
+
forgesight.configure(exporters=[
|
|
44
|
+
LangfuseExporter(public_key="pk-lf-...", secret_key="sk-lf-...",
|
|
45
|
+
host="https://cloud.langfuse.com"),
|
|
46
|
+
])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or by name: `exporters: [{name: langfuse, config: {public_key: …, secret_key: …}}]`.
|
|
50
|
+
|
|
51
|
+
## Two paths
|
|
52
|
+
|
|
53
|
+
- **First-party (this package):** native `langfuse.*` observation mapping + the SDK's
|
|
54
|
+
computed cost (`forgesight.usage.cost_usd`) ingested.
|
|
55
|
+
- **OTLP-native (no package):** point `forgesight-otel` at
|
|
56
|
+
`https://cloud.langfuse.com/api/public/otel` with `Authorization: Basic base64(pk:sk)`.
|
|
57
|
+
|
|
58
|
+
Prompt/response content is captured only with `capture_content=True` (off by default).
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
Apache-2.0
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# forgesight-langfuse
|
|
2
|
+
|
|
3
|
+
The Langfuse exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
|
|
4
|
+
Ships ForgeSight records to Langfuse over its OTLP ingest endpoint
|
|
5
|
+
(`/api/public/otel`, Basic auth), enriched with the native **`langfuse.*`**
|
|
6
|
+
attributes so LLM calls render as **generation** observations, tools as **tool**
|
|
7
|
+
observations, and the run's `user`/`session`/`tags` lift to the trace.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install forgesight-langfuse
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import forgesight
|
|
15
|
+
from forgesight_langfuse import LangfuseExporter
|
|
16
|
+
|
|
17
|
+
forgesight.configure(exporters=[
|
|
18
|
+
LangfuseExporter(public_key="pk-lf-...", secret_key="sk-lf-...",
|
|
19
|
+
host="https://cloud.langfuse.com"),
|
|
20
|
+
])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or by name: `exporters: [{name: langfuse, config: {public_key: …, secret_key: …}}]`.
|
|
24
|
+
|
|
25
|
+
## Two paths
|
|
26
|
+
|
|
27
|
+
- **First-party (this package):** native `langfuse.*` observation mapping + the SDK's
|
|
28
|
+
computed cost (`forgesight.usage.cost_usd`) ingested.
|
|
29
|
+
- **OTLP-native (no package):** point `forgesight-otel` at
|
|
30
|
+
`https://cloud.langfuse.com/api/public/otel` with `Authorization: Basic base64(pk:sk)`.
|
|
31
|
+
|
|
32
|
+
Prompt/response content is captured only with `capture_content=True` (off by default).
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
Apache-2.0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "forgesight-langfuse"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "ForgeSight Langfuse exporter — OTLP ingest with native langfuse.* observation mapping."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "kjoshi" }]
|
|
9
|
+
keywords = ["observability", "langfuse", "llm", "ai-agents", "forgesight", "otlp"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Intended Audience :: Information Technology",
|
|
14
|
+
"Topic :: System :: Monitoring",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["forgesight-core", "forgesight-otel"]
|
|
23
|
+
|
|
24
|
+
[project.entry-points."forgesight.exporters"]
|
|
25
|
+
langfuse = "forgesight_langfuse.exporter:LangfuseExporter"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Scaffoldic/forgesight"
|
|
29
|
+
Repository = "https://github.com/Scaffoldic/forgesight"
|
|
30
|
+
Issues = "https://github.com/Scaffoldic/forgesight/issues"
|
|
31
|
+
Changelog = "https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/forgesight_langfuse"]
|
|
39
|
+
|
|
40
|
+
[tool.uv.sources]
|
|
41
|
+
forgesight-core = { workspace = true }
|
|
42
|
+
forgesight-otel = { workspace = true }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""ForgeSight Langfuse exporter — OTLP ingest with native langfuse.* observation mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .exporter import LangfuseExporter, basic_auth_header
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["LangfuseExporter", "__version__", "basic_auth_header"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""``LangfuseExporter`` — ForgeSight records → Langfuse via OTLP + ``langfuse.*`` attrs.
|
|
2
|
+
|
|
3
|
+
Wraps ``forgesight-otel``'s ``OTelExporter`` pointed at Langfuse's OTLP ingest endpoint
|
|
4
|
+
(``/api/public/otel``, HTTP, Basic auth) and enriches each record with the native
|
|
5
|
+
``langfuse.*`` attributes Langfuse reads (observation type; trace name / user / session /
|
|
6
|
+
tags). LLM calls land as ``generation`` observations, tools as ``tool`` observations,
|
|
7
|
+
steps as ``span`` — with the SDK's computed ``forgesight.usage.cost_usd`` ingested.
|
|
8
|
+
|
|
9
|
+
Content (prompts/completions) is captured only when ``capture_content`` is on (P7).
|
|
10
|
+
``export`` never raises (P6). Runs on the pipeline worker, never the hot path.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from types import MappingProxyType
|
|
19
|
+
|
|
20
|
+
from opentelemetry.sdk.trace.export import SpanExporter
|
|
21
|
+
|
|
22
|
+
from forgesight_api import ExportResult, Kind, Record
|
|
23
|
+
from forgesight_otel import OTelExporter
|
|
24
|
+
|
|
25
|
+
_REGION_HOSTS = {
|
|
26
|
+
"us": "https://us.cloud.langfuse.com",
|
|
27
|
+
"eu": "https://cloud.langfuse.com",
|
|
28
|
+
}
|
|
29
|
+
_OBSERVATION_TYPE = {
|
|
30
|
+
Kind.AGENT: "agent",
|
|
31
|
+
Kind.WORKFLOW: "chain",
|
|
32
|
+
Kind.STEP: "span",
|
|
33
|
+
Kind.LLM: "generation",
|
|
34
|
+
Kind.TOOL: "tool",
|
|
35
|
+
Kind.MCP: "tool",
|
|
36
|
+
}
|
|
37
|
+
# business-metadata keys lifted to trace-level langfuse attributes (on the root span)
|
|
38
|
+
_TRACE_LIFTS = {
|
|
39
|
+
"user_id": "langfuse.user.id",
|
|
40
|
+
"session_id": "langfuse.session.id",
|
|
41
|
+
"tags": "langfuse.trace.tags",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
LANGFUSE_OBSERVATION_TYPE = "langfuse.observation.type"
|
|
45
|
+
LANGFUSE_TRACE_NAME = "langfuse.trace.name"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def basic_auth_header(public_key: str, secret_key: str) -> str:
|
|
49
|
+
"""Return the ``Basic base64(pk:sk)`` value Langfuse's OTLP endpoint expects."""
|
|
50
|
+
token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode("ascii")
|
|
51
|
+
return f"Basic {token}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def otlp_traces_endpoint(host: str) -> str:
|
|
55
|
+
"""Langfuse's OTLP/HTTP traces URL: the ``/v1/traces`` signal under ``/api/public/otel``.
|
|
56
|
+
The signal path is required — posting to the bare ``/api/public/otel`` base 404s."""
|
|
57
|
+
return f"{host.rstrip('/')}/api/public/otel/v1/traces"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LangfuseExporter:
|
|
61
|
+
"""Export records to Langfuse over OTLP with native ``langfuse.*`` enrichment."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
public_key: str,
|
|
67
|
+
secret_key: str,
|
|
68
|
+
host: str | None = None,
|
|
69
|
+
region: str | None = None,
|
|
70
|
+
capture_content: bool = False,
|
|
71
|
+
span_exporter: SpanExporter | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if not public_key or not secret_key:
|
|
74
|
+
raise ValueError("LangfuseExporter requires public_key and secret_key")
|
|
75
|
+
resolved_host = host or _REGION_HOSTS.get((region or "").lower(), _REGION_HOSTS["eu"])
|
|
76
|
+
self._host = resolved_host.rstrip("/")
|
|
77
|
+
self._otel = OTelExporter(
|
|
78
|
+
endpoint=otlp_traces_endpoint(self._host),
|
|
79
|
+
protocol="http/protobuf",
|
|
80
|
+
service_name="forgesight",
|
|
81
|
+
capture_content=capture_content,
|
|
82
|
+
headers={"Authorization": basic_auth_header(public_key, secret_key)},
|
|
83
|
+
span_exporter=span_exporter,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# --- TelemetryExporter Protocol --------------------------------------
|
|
87
|
+
def export(self, records: Sequence[Record]) -> ExportResult:
|
|
88
|
+
return self._otel.export([self._enrich(r) for r in records])
|
|
89
|
+
|
|
90
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
91
|
+
return self._otel.force_flush(timeout_millis)
|
|
92
|
+
|
|
93
|
+
def shutdown(self, timeout_millis: int = 30_000) -> None:
|
|
94
|
+
self._otel.shutdown(timeout_millis)
|
|
95
|
+
|
|
96
|
+
# --- internals --------------------------------------------------------
|
|
97
|
+
def _enrich(self, record: Record) -> Record:
|
|
98
|
+
attrs: dict[str, object] = dict(record.attributes)
|
|
99
|
+
attrs[LANGFUSE_OBSERVATION_TYPE] = _OBSERVATION_TYPE[record.kind]
|
|
100
|
+
if record.parent_span_id is None and record.kind in (Kind.AGENT, Kind.WORKFLOW):
|
|
101
|
+
attrs[LANGFUSE_TRACE_NAME] = record.name
|
|
102
|
+
for meta_key, lf_key in _TRACE_LIFTS.items():
|
|
103
|
+
if meta_key in attrs:
|
|
104
|
+
attrs[lf_key] = attrs[meta_key]
|
|
105
|
+
return replace(record, attributes=MappingProxyType(attrs))
|
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for the Langfuse exporter: auth, observation mapping, trace lift, conformance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
9
|
+
|
|
10
|
+
from forgesight_core import configure, reset_runtime, telemetry
|
|
11
|
+
from forgesight_core.testing.conformance import run_exporter_conformance
|
|
12
|
+
from forgesight_langfuse import LangfuseExporter, basic_auth_header
|
|
13
|
+
from forgesight_langfuse.exporter import otlp_traces_endpoint
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _exporter(sink: InMemorySpanExporter, **kw: object) -> LangfuseExporter:
|
|
17
|
+
return LangfuseExporter(
|
|
18
|
+
public_key="pk-lf-test", secret_key="sk-lf-test", span_exporter=sink, **kw
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_basic_auth_header() -> None:
|
|
23
|
+
header = basic_auth_header("pk-lf-1", "sk-lf-2")
|
|
24
|
+
assert header.startswith("Basic ")
|
|
25
|
+
decoded = base64.b64decode(header.split(" ", 1)[1]).decode()
|
|
26
|
+
assert decoded == "pk-lf-1:sk-lf-2"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_requires_keys() -> None:
|
|
30
|
+
with pytest.raises(ValueError, match="public_key and secret_key"):
|
|
31
|
+
LangfuseExporter(public_key="", secret_key="sk")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_region_resolves_host() -> None:
|
|
35
|
+
exporter = LangfuseExporter(public_key="pk", secret_key="sk", region="us")
|
|
36
|
+
assert exporter._host == "https://us.cloud.langfuse.com"
|
|
37
|
+
explicit = LangfuseExporter(public_key="pk", secret_key="sk", host="https://self/")
|
|
38
|
+
assert explicit._host == "https://self"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_conformance() -> None:
|
|
42
|
+
run_exporter_conformance(
|
|
43
|
+
lambda: LangfuseExporter(
|
|
44
|
+
public_key="pk-lf", secret_key="sk-lf", span_exporter=InMemorySpanExporter()
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_observation_types_and_trace_lift() -> None:
|
|
50
|
+
sink = InMemorySpanExporter()
|
|
51
|
+
configure(exporters=[_exporter(sink)], sync_export=True)
|
|
52
|
+
try:
|
|
53
|
+
with telemetry.agent_run("classifier") as run:
|
|
54
|
+
run.set_metadata(user_id="u-1", session_id="s-1")
|
|
55
|
+
with run.step("react-1"), run.llm_call("anthropic", "claude-sonnet-4-5") as call:
|
|
56
|
+
call.record_usage(input=10, output=5)
|
|
57
|
+
with run.tool_call("search"):
|
|
58
|
+
pass
|
|
59
|
+
spans = {s.name: s for s in sink.get_finished_spans()}
|
|
60
|
+
by_attr = {
|
|
61
|
+
s.attributes["langfuse.observation.type"]: s # type: ignore[index]
|
|
62
|
+
for s in sink.get_finished_spans()
|
|
63
|
+
if s.attributes
|
|
64
|
+
}
|
|
65
|
+
assert "generation" in by_attr # the LLM call
|
|
66
|
+
assert "tool" in by_attr # the tool call
|
|
67
|
+
assert "agent" in by_attr # the run
|
|
68
|
+
agent_span = next(s for s in sink.get_finished_spans() if s.name.startswith("invoke_agent"))
|
|
69
|
+
assert agent_span.attributes is not None
|
|
70
|
+
assert agent_span.attributes["langfuse.trace.name"] == "classifier"
|
|
71
|
+
assert agent_span.attributes["langfuse.user.id"] == "u-1"
|
|
72
|
+
assert agent_span.attributes["langfuse.session.id"] == "s-1"
|
|
73
|
+
assert spans # sanity
|
|
74
|
+
finally:
|
|
75
|
+
reset_runtime()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_cost_is_ingested_as_extension_attr() -> None:
|
|
79
|
+
sink = InMemorySpanExporter()
|
|
80
|
+
configure(exporters=[_exporter(sink)], sync_export=True)
|
|
81
|
+
try:
|
|
82
|
+
with (
|
|
83
|
+
telemetry.agent_run("c") as run,
|
|
84
|
+
run.llm_call("anthropic", "claude-sonnet-4-5") as call,
|
|
85
|
+
):
|
|
86
|
+
call.record_usage(input=1000, output=500)
|
|
87
|
+
gen = next(
|
|
88
|
+
s
|
|
89
|
+
for s in sink.get_finished_spans()
|
|
90
|
+
if s.attributes and s.attributes.get("langfuse.observation.type") == "generation"
|
|
91
|
+
)
|
|
92
|
+
assert gen.attributes is not None
|
|
93
|
+
assert gen.attributes["forgesight.usage.cost_usd"] is not None # SDK cost ingested
|
|
94
|
+
finally:
|
|
95
|
+
reset_runtime()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_otlp_traces_endpoint_has_signal_path() -> None:
|
|
99
|
+
# Langfuse's OTLP/HTTP ingest needs the /v1/traces signal path; the bare base 404s.
|
|
100
|
+
assert (
|
|
101
|
+
otlp_traces_endpoint("https://us.cloud.langfuse.com")
|
|
102
|
+
== "https://us.cloud.langfuse.com/api/public/otel/v1/traces"
|
|
103
|
+
)
|
|
104
|
+
assert (
|
|
105
|
+
otlp_traces_endpoint("https://self-hosted.example/")
|
|
106
|
+
== "https://self-hosted.example/api/public/otel/v1/traces"
|
|
107
|
+
)
|