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.
@@ -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))
@@ -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
+ )