provenance-telemetry 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.
- provenance_telemetry-0.1.0/.github/workflows/build-package.yml +106 -0
- provenance_telemetry-0.1.0/.github/workflows/test.yml +105 -0
- provenance_telemetry-0.1.0/.gitignore +7 -0
- provenance_telemetry-0.1.0/LICENSE +21 -0
- provenance_telemetry-0.1.0/PKG-INFO +203 -0
- provenance_telemetry-0.1.0/README.md +173 -0
- provenance_telemetry-0.1.0/docs/durable-execution.md +131 -0
- provenance_telemetry-0.1.0/examples/mesh-mapping.example.yaml +27 -0
- provenance_telemetry-0.1.0/provenance_telemetry/__init__.py +26 -0
- provenance_telemetry-0.1.0/provenance_telemetry/emit.py +151 -0
- provenance_telemetry-0.1.0/provenance_telemetry/mapping.py +69 -0
- provenance_telemetry-0.1.0/provenance_telemetry/redact.py +25 -0
- provenance_telemetry-0.1.0/provenance_telemetry/spans.py +111 -0
- provenance_telemetry-0.1.0/pyproject.toml +67 -0
- provenance_telemetry-0.1.0/tests/test_fail_soft.py +34 -0
- provenance_telemetry-0.1.0/tests/test_mapping_validation.py +29 -0
- provenance_telemetry-0.1.0/tests/test_no_mesh_vocab.py +44 -0
- provenance_telemetry-0.1.0/tests/test_path_equivalence.py +40 -0
- provenance_telemetry-0.1.0/tests/test_traced.py +27 -0
- provenance_telemetry-0.1.0/tests/test_v4.py +33 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
name: Build Python Package
|
|
2
|
+
|
|
3
|
+
# Builds the provenance-telemetry sdist+wheel on every push and PR so consumers
|
|
4
|
+
# can grab the latest from a workflow artifact. On tag pushes (vX.Y.Z) the
|
|
5
|
+
# artifacts are attached to a GitHub Release AND published to PyPI (trusted
|
|
6
|
+
# publishing — OIDC, no API token stored anywhere).
|
|
7
|
+
#
|
|
8
|
+
# Distribution name and import package agree: `pip install provenance-telemetry`
|
|
9
|
+
# -> `import provenance_telemetry`.
|
|
10
|
+
#
|
|
11
|
+
# pip install provenance-telemetry
|
|
12
|
+
# # or pin directly from git, to a TAG and never a branch:
|
|
13
|
+
# uv add "provenance-telemetry @ git+https://github.com/edgy-solutions/provenance-telemetry.git@v0.1.0"
|
|
14
|
+
#
|
|
15
|
+
# ONE-TIME SETUP for the publish job (it cannot work until this is done):
|
|
16
|
+
# 1. On PyPI: Account -> Publishing -> add a GitHub trusted publisher with
|
|
17
|
+
# owner `edgy-solutions`, repo `provenance-telemetry`,
|
|
18
|
+
# workflow `build-package.yml`, environment `pypi`.
|
|
19
|
+
# 2. In this repo: Settings -> Environments -> create `pypi`.
|
|
20
|
+
# Until step 1 exists the tag build still succeeds and the Release is still
|
|
21
|
+
# created; only the publish job fails, and it fails LOUDLY rather than silently
|
|
22
|
+
# skipping — a release that quietly did not publish is the worst outcome here.
|
|
23
|
+
|
|
24
|
+
on:
|
|
25
|
+
push:
|
|
26
|
+
branches: [master, main]
|
|
27
|
+
tags: ['v*']
|
|
28
|
+
pull_request:
|
|
29
|
+
branches: [master, main]
|
|
30
|
+
workflow_dispatch:
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
contents: write # needed to publish the wheel as a release asset on tag pushes
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
build:
|
|
37
|
+
name: Build wheel
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- name: Checkout
|
|
41
|
+
uses: actions/checkout@v5
|
|
42
|
+
|
|
43
|
+
- name: Install uv
|
|
44
|
+
uses: astral-sh/setup-uv@v7
|
|
45
|
+
|
|
46
|
+
- name: Build wheel + sdist
|
|
47
|
+
run: uv build # both sdist + wheel (PyPI wants both)
|
|
48
|
+
|
|
49
|
+
# The tag and the packaged version must agree. Publishing v0.1.1 from a
|
|
50
|
+
# tree whose pyproject still says 0.1.0 yields an immutable PyPI release
|
|
51
|
+
# under the wrong number that can never be corrected, only yanked.
|
|
52
|
+
- name: Assert the tag matches the packaged version
|
|
53
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
54
|
+
run: |
|
|
55
|
+
TAG="${GITHUB_REF#refs/tags/v}"
|
|
56
|
+
VER=$(ls dist/*.tar.gz | sed -E 's/.*-([0-9][^-]*)\.tar\.gz/\1/')
|
|
57
|
+
echo "tag=$TAG packaged=$VER"
|
|
58
|
+
test "$TAG" = "$VER" || {
|
|
59
|
+
echo "::error::tag v$TAG does not match packaged version $VER —"
|
|
60
|
+
echo "::error::bump the version field in pyproject.toml, or retag."
|
|
61
|
+
exit 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# A wheel that imports on the build machine but not from the artifact is
|
|
65
|
+
# the packaging equivalent of a passing suite over a broken image. Install
|
|
66
|
+
# the built wheel into a clean venv and import it.
|
|
67
|
+
- name: Smoke-test the built wheel in a clean venv
|
|
68
|
+
run: |
|
|
69
|
+
uv venv --python 3.12 /tmp/smoke
|
|
70
|
+
VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl
|
|
71
|
+
/tmp/smoke/bin/python -c "import provenance_telemetry as pt; print('ok', sorted(pt.__all__))"
|
|
72
|
+
|
|
73
|
+
- name: Upload as workflow artifact
|
|
74
|
+
uses: actions/upload-artifact@v6
|
|
75
|
+
with:
|
|
76
|
+
name: provenance-telemetry-dist
|
|
77
|
+
path: dist/*
|
|
78
|
+
if-no-files-found: error
|
|
79
|
+
retention-days: 30
|
|
80
|
+
|
|
81
|
+
- name: Attach to GitHub Release (on tag)
|
|
82
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
83
|
+
uses: softprops/action-gh-release@v3
|
|
84
|
+
with:
|
|
85
|
+
files: dist/*
|
|
86
|
+
generate_release_notes: true
|
|
87
|
+
fail_on_unmatched_files: true
|
|
88
|
+
|
|
89
|
+
publish-pypi:
|
|
90
|
+
name: Publish to PyPI
|
|
91
|
+
needs: build
|
|
92
|
+
# Only publish on a version tag push (e.g. v0.1.0), never on branch/PR builds.
|
|
93
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
environment: pypi
|
|
96
|
+
permissions:
|
|
97
|
+
id-token: write # OIDC token for PyPI Trusted Publishing — no API token/secret needed
|
|
98
|
+
steps:
|
|
99
|
+
- name: Download built distributions
|
|
100
|
+
uses: actions/download-artifact@v8
|
|
101
|
+
with:
|
|
102
|
+
name: provenance-telemetry-dist
|
|
103
|
+
path: dist
|
|
104
|
+
|
|
105
|
+
- name: Publish to PyPI
|
|
106
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
# Runs the suite on every push and PR, across the FULL supported Python range.
|
|
4
|
+
#
|
|
5
|
+
# The matrix is the point, not decoration. Before this workflow existed the
|
|
6
|
+
# package declared `requires-python = ">=3.9"` while two of its four dependencies
|
|
7
|
+
# (langfuse and opentelemetry-api) refuse anything below 3.10 — so the floor was
|
|
8
|
+
# not merely untested, it was FALSE, and the failure it produced would have named
|
|
9
|
+
# langfuse rather than this package. A support range nothing exercises is a claim;
|
|
10
|
+
# running the floor and the ceiling turns it into a measurement.
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: [master, main]
|
|
15
|
+
pull_request:
|
|
16
|
+
branches: [master, main]
|
|
17
|
+
workflow_dispatch:
|
|
18
|
+
|
|
19
|
+
concurrency:
|
|
20
|
+
group: tests-${{ github.ref }}
|
|
21
|
+
cancel-in-progress: true
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
test:
|
|
25
|
+
name: py${{ matrix.python }}
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
strategy:
|
|
28
|
+
fail-fast: false
|
|
29
|
+
matrix:
|
|
30
|
+
# Floor and ceiling of `requires-python = ">=3.10,<3.15"`, plus the
|
|
31
|
+
# versions in between. If a future dependency bump raises the real floor,
|
|
32
|
+
# the 3.10 leg fails HERE rather than in an adopter's install.
|
|
33
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- name: Checkout
|
|
37
|
+
uses: actions/checkout@v5
|
|
38
|
+
|
|
39
|
+
- name: Install uv
|
|
40
|
+
uses: astral-sh/setup-uv@v7
|
|
41
|
+
|
|
42
|
+
- name: Create venv
|
|
43
|
+
run: uv venv --python ${{ matrix.python }}
|
|
44
|
+
|
|
45
|
+
- name: Install package + dev extra
|
|
46
|
+
run: uv pip install -e ".[dev]"
|
|
47
|
+
|
|
48
|
+
- name: Show resolved versions
|
|
49
|
+
run: uv pip list | grep -Ei "^(langfuse|opentelemetry|pydantic|PyYAML|pytest)" || true
|
|
50
|
+
|
|
51
|
+
# The suite must run with Langfuse DISABLED. Every entry point in this
|
|
52
|
+
# package gates on LANGFUSE_SECRET_KEY + LANGFUSE_PUBLIC_KEY being present,
|
|
53
|
+
# so a runner that happened to carry real credentials would take entirely
|
|
54
|
+
# different code paths AND emit test spans into a real project. Unset
|
|
55
|
+
# explicitly rather than trusting the runner to be clean.
|
|
56
|
+
- name: Run tests (telemetry disabled)
|
|
57
|
+
env:
|
|
58
|
+
LANGFUSE_SECRET_KEY: ""
|
|
59
|
+
LANGFUSE_PUBLIC_KEY: ""
|
|
60
|
+
LANGFUSE_HOST: ""
|
|
61
|
+
run: |
|
|
62
|
+
set -o pipefail
|
|
63
|
+
.venv/bin/python -m pytest tests -q --no-header -rs \
|
|
64
|
+
--junitxml=junit-${{ matrix.python }}.xml 2>&1 | tee pytest-output.txt
|
|
65
|
+
|
|
66
|
+
# A skipped test is indistinguishable from a passing one in a summary line.
|
|
67
|
+
# This package's suite is designed to run without credentials, so a skip
|
|
68
|
+
# citing a missing import means CI is under-installed — the coverage
|
|
69
|
+
# quietly shrank and the job still went green.
|
|
70
|
+
- name: Fail on dependency-driven skips
|
|
71
|
+
if: always()
|
|
72
|
+
run: |
|
|
73
|
+
if grep -E "SKIPPED.*(could not import|not installed|No module named)" pytest-output.txt; then
|
|
74
|
+
echo "::error::Tests skipped because a dependency is missing. CI is under-installed —"
|
|
75
|
+
echo "::error::add the package so these tests actually run instead of silently passing."
|
|
76
|
+
exit 1
|
|
77
|
+
fi
|
|
78
|
+
echo "No dependency-driven skips."
|
|
79
|
+
|
|
80
|
+
# The public surface is the contract. An export that disappears is a
|
|
81
|
+
# breaking change for every adopter, and nothing else in the suite would
|
|
82
|
+
# notice — the README documents exactly these names.
|
|
83
|
+
- name: Assert the documented public surface still exists
|
|
84
|
+
run: |
|
|
85
|
+
.venv/bin/python - <<'PY'
|
|
86
|
+
import provenance_telemetry as pt
|
|
87
|
+
documented = {"Mapping","ScoreSpec","load_mapping","KNOWN_SLOTS",
|
|
88
|
+
"KNOWN_SCORE_ENCODINGS","set_trace_standard","observed_trace",
|
|
89
|
+
"emit_misses","span_descriptor","litellm_metadata",
|
|
90
|
+
"observe_span","traced","redact"}
|
|
91
|
+
missing = sorted(n for n in documented if not hasattr(pt, n))
|
|
92
|
+
assert not missing, f"public surface lost: {missing}"
|
|
93
|
+
extra = sorted(set(pt.__all__) - documented)
|
|
94
|
+
assert not extra, f"exported but undocumented in README: {extra}"
|
|
95
|
+
print(f"public surface intact ({len(documented)} names)")
|
|
96
|
+
PY
|
|
97
|
+
|
|
98
|
+
- name: Upload test results
|
|
99
|
+
if: always()
|
|
100
|
+
uses: actions/upload-artifact@v6
|
|
101
|
+
with:
|
|
102
|
+
name: junit-py${{ matrix.python }}
|
|
103
|
+
path: junit-${{ matrix.python }}.xml
|
|
104
|
+
if-no-files-found: warn
|
|
105
|
+
retention-days: 14
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edgy Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provenance-telemetry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Project an existing provenance doctrine into an observability backend. Vocabulary is config, not code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/edgy-solutions/provenance-telemetry
|
|
6
|
+
Project-URL: Repository, https://github.com/edgy-solutions/provenance-telemetry
|
|
7
|
+
Project-URL: Issues, https://github.com/edgy-solutions/provenance-telemetry/issues
|
|
8
|
+
Author-email: Chris Nogradi <cnogradi@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: langfuse,llm,observability,opentelemetry,provenance,telemetry,tracing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: <3.15,>=3.10
|
|
23
|
+
Requires-Dist: langfuse>=3.0.0
|
|
24
|
+
Requires-Dist: opentelemetry-api>=1.20.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# provenance-telemetry
|
|
32
|
+
|
|
33
|
+
**Telemetry is your provenance doctrine projected into an observability backend:** trace identity from artifact keys (never LLM-derived), user/session from your identity key, span attributes and scores from the fields your system of record already uses — so a trace and a decision record tell the same story *in the same words*, and adopters inherit the **vocabulary, not just the plumbing**.
|
|
34
|
+
|
|
35
|
+
**The dependency list *is* the adoption pitch.** Four dependencies — `langfuse`, `opentelemetry-api`, `pydantic`, `pyyaml` — one doctrine, config-governed vocabulary. This library ships **no vocabulary of its own**: which of *your* provenance fields map onto which Langfuse slot is a small ratifiable mapping config *you* supply. That is what lets any team adopt it without importing anyone else's domain — and what keeps a security review's read of this `README` + `pyproject` short.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```toml
|
|
42
|
+
# pyproject.toml — from PyPI (preferred)
|
|
43
|
+
dependencies = ["provenance-telemetry>=0.1.0,<0.2"]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
# pyproject.toml — direct from git, pinned to a TAG (pre-index form)
|
|
48
|
+
dependencies = [
|
|
49
|
+
"provenance-telemetry @ git+https://github.com/edgy-solutions/provenance-telemetry.git@v0.1.0",
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Never a floating ref.** A `git+https://…` with no `@ref` resolves to the default branch **at build time**, so a single upstream commit changes what every consuming service ships — with nobody deciding, and no diff in the consumer's repo to review. This is not hypothetical: a sibling package renamed its *distribution* on `master`, and every consumer floating on that branch broke on the next clean resolve, while machines holding a stale install kept working so the repo looked healthy. Pin to a tag, or to a version once it is on an index.
|
|
54
|
+
|
|
55
|
+
> **The distribution name is `provenance-telemetry`; the import name is `provenance_telemetry`.** These are different namespaces and they are allowed to diverge — which is exactly how the failure above happened, because a rename of one is invisible to code that only ever types the other. **The import name is the load-bearing one and will not change without a major version.** If the distribution is ever renamed, `import provenance_telemetry` keeps working and only your dependency *declaration* needs editing.
|
|
56
|
+
|
|
57
|
+
## Enabling it
|
|
58
|
+
|
|
59
|
+
Everything in this library is **inert unless both** of these are set:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
LANGFUSE_PUBLIC_KEY=pk-...
|
|
63
|
+
LANGFUSE_SECRET_KEY=sk-...
|
|
64
|
+
LANGFUSE_HOST=https://langfuse.example.com # read by the langfuse SDK itself
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With either missing, every entry point becomes a pass-through: context managers yield, decorators return the undecorated function, emitters return without emitting. **No-credentials code carries no runtime dependency on Langfuse being reachable.**
|
|
68
|
+
|
|
69
|
+
> ⚠️ **`traced` gates at DECORATION time**, i.e. at module import. If you set the environment *after* importing the module that uses `@traced`, the decorator has already resolved to pass-through. This matches the deployment model (env set before the process starts) but bites in notebooks and tests. `observed_trace`, `observe_span` and `set_trace_standard` gate at **call** time and do not have this constraint.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Quickstart — a traced service entry
|
|
74
|
+
|
|
75
|
+
The shape used across a real ten-engine mesh: join the caller's trace on an inbound header, enrich it from your provenance values, and let everything inside nest automatically.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import FastAPI, Request
|
|
79
|
+
from provenance_telemetry import load_mapping, observed_trace, observe_span
|
|
80
|
+
|
|
81
|
+
MAPPING = load_mapping("config/mesh-mapping.yaml") # shape-validated at load
|
|
82
|
+
app = FastAPI()
|
|
83
|
+
|
|
84
|
+
@app.post("/analyze")
|
|
85
|
+
async def analyze(req: Request, body: dict):
|
|
86
|
+
values = {
|
|
87
|
+
"request_key": req.headers.get("X-Trace-Id"), # -> trace_id slot: THE JOIN
|
|
88
|
+
"authz_id": req.headers.get("X-Authz-Id"), # -> user_id slot
|
|
89
|
+
"session_id": body.get("session_id"),
|
|
90
|
+
"engine": "analyst",
|
|
91
|
+
"verb": "analyzeWithCodeAgent",
|
|
92
|
+
"domain": body.get("domain"),
|
|
93
|
+
}
|
|
94
|
+
with observed_trace(MAPPING, values, name="analyze"):
|
|
95
|
+
with observe_span("plan", verb=values["verb"]):
|
|
96
|
+
plan = build_plan(body)
|
|
97
|
+
return run(plan)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**What makes this a join and not a new trace:** `observed_trace` reads the field your mapping puts in the `trace_id` slot (`request_key` above) and derives a deterministic Langfuse id from it via `create_trace_id(seed=...)`. Two services handed the **same** `X-Trace-Id` compute the **same** Langfuse trace id, so their spans land on **one** trace. The join is native, not a reconciliation step.
|
|
101
|
+
|
|
102
|
+
If the seed field is absent, `observed_trace` opens a fresh trace instead — which is correct, but means **a caller that forgets the header silently gets an orphan trace rather than an error.** If joins are load-bearing for you, assert the header's presence in your own code; this library will not refuse the work to protect the telemetry.
|
|
103
|
+
|
|
104
|
+
### Propagating the trace to the next hop
|
|
105
|
+
|
|
106
|
+
The join only holds if the id travels. Send it on every outbound call:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
headers = {"X-Trace-Id": values["request_key"], "X-Authz-Id": values["authz_id"]}
|
|
110
|
+
httpx.post(next_engine_url, json=payload, headers=headers)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
### `load_mapping(source) -> Mapping`
|
|
118
|
+
|
|
119
|
+
Accepts a path or a dict. **Shape-validated at load**: an unknown Langfuse slot or score encoding raises `pydantic.ValidationError` immediately.
|
|
120
|
+
|
|
121
|
+
Two-tier validation is deliberate. This library validates **shape** — `KNOWN_SLOTS` (`trace_id`, `user_id`, `session_id`, `release`, `version`) and `KNOWN_SCORE_ENCODINGS` (`ordinal`, `fraction`, `binary_per_join`, `count_total`) are closed sets. It does **not** validate that the provenance field names on the right-hand side exist — that is **truth**, and it belongs with whoever owns the vocabulary. Add a CI check in *your* repo asserting your mapping's field names exist in your contracts; that is the half this library cannot do for you, and skipping it is how a mapping quietly projects `None` forever.
|
|
122
|
+
|
|
123
|
+
### `observed_trace(mapping, values, *, name="operation", as_type="span")`
|
|
124
|
+
|
|
125
|
+
Context manager. **The entry primitive** — opens or *joins* a trace on the mapped `trace_id`, then enriches it. Everything opened inside nests under it via the ordinary OTel context.
|
|
126
|
+
|
|
127
|
+
### `set_trace_standard(mapping, values)`
|
|
128
|
+
|
|
129
|
+
Enrich the **current** trace in place: identity slots, tags, metadata as OTel attributes, plus scores. Use when something you learned *later* in the request belongs on the trace.
|
|
130
|
+
|
|
131
|
+
**It cannot set the trace id** — under Langfuse v4 a trace's id is fixed at creation. Choose the id at the entry with `observed_trace`. Calling this with a `trace_id` slot mapped is not an error; the slot is simply skipped.
|
|
132
|
+
|
|
133
|
+
### `observe_span(operation, **attributes)` / `litellm_metadata(operation, ...)`
|
|
134
|
+
|
|
135
|
+
Two carriers for **one** span shape (both derive from `span_descriptor`), so a direct-provider call and a LiteLLM-proxied call emit comparable spans:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# direct provider — explicit span; a generation created inside nests automatically
|
|
139
|
+
with observe_span("classify", model="gpt-oss", domain=domain) as d:
|
|
140
|
+
result = client.chat.completions.create(...)
|
|
141
|
+
|
|
142
|
+
# through a LiteLLM proxy — feed the callback metadata instead
|
|
143
|
+
import litellm
|
|
144
|
+
litellm.completion(model="gpt-4o-mini", messages=msgs,
|
|
145
|
+
metadata=litellm_metadata("classify", trace_id=tid, user_id=uid,
|
|
146
|
+
session_id=sid, tags=["analyst"], domain=domain))
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `traced(name=None, as_type=None)`
|
|
150
|
+
|
|
151
|
+
Decorator that opens a span/trace around a function. For a top-level entry that must open on a **chosen** id, use `observed_trace` — `traced` wraps Langfuse's `observe`, which mints its own id and therefore **cannot join**. See the decoration-time gating warning above.
|
|
152
|
+
|
|
153
|
+
### `redact(value) -> str`
|
|
154
|
+
|
|
155
|
+
**Hash, don't drop.** Returns `sha1:<16 hex>`. A redacted trace stays *joinable* — two traces referencing the same value still collide on the hash — without carrying the content. Applied automatically to any field listed in `content_bearing`; call it directly for ad-hoc values.
|
|
156
|
+
|
|
157
|
+
`is_redacted(value)` also exists but is **not** exported at top level in this release — import it as `from provenance_telemetry.redact import is_redacted`.
|
|
158
|
+
|
|
159
|
+
### `emit_misses() -> int`
|
|
160
|
+
|
|
161
|
+
**Fail-soft is only honest if it is countable.** Every soft failure — SDK absent, Langfuse unreachable, a bad score, an enrichment error — increments this counter and logs a warning; nothing raises. Telemetry must not stop the work it observes, but *silent* telemetry loss is indistinguishable from a healthy quiet system. Export it:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
@app.get("/health")
|
|
165
|
+
def health():
|
|
166
|
+
return {"status": "ok", "telemetry_misses": emit_misses()}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
A rising count means you are flying blind and do not know it. Alert on the **derivative**, not the value.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## The mapping config (you supply this)
|
|
174
|
+
|
|
175
|
+
```yaml
|
|
176
|
+
version: 1
|
|
177
|
+
slots: # langfuse slot -> your provenance field
|
|
178
|
+
trace_id: request_key
|
|
179
|
+
user_id: authz_id
|
|
180
|
+
session_id: session_id
|
|
181
|
+
tags: [engine, verb, domain]
|
|
182
|
+
metadata: [subject_class, resolved_via, chart_version]
|
|
183
|
+
scores: # honest-degradation signals, projected from your schema
|
|
184
|
+
confidence_tier: {encoding: ordinal}
|
|
185
|
+
needs_review: {encoding: fraction}
|
|
186
|
+
coherence: {encoding: binary_per_join}
|
|
187
|
+
crops_failed: {encoding: count_total}
|
|
188
|
+
content_bearing: [mpn, notice_id, snippet, matched_text, override_reason]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The field *names* on the right are yours. This library never names them in its source (a deletion seal enforces it) — they live only in your mapping, checked for truth by you. See [`examples/mesh-mapping.example.yaml`](examples/mesh-mapping.example.yaml).
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Recipes
|
|
196
|
+
|
|
197
|
+
- **[Durable execution (Restate, Temporal, any replaying runtime)](docs/durable-execution.md)** — the entry primitive is **not** replay-safe on its own, and the failure is invisible in a green test suite. Read this before instrumenting a durable handler.
|
|
198
|
+
|
|
199
|
+
## Non-goals
|
|
200
|
+
|
|
201
|
+
- **It will not refuse your work.** Every failure path is soft. If a trace *must* exist for compliance, that is a check you own; this library is a witness channel, not a gate.
|
|
202
|
+
- **It does not own your vocabulary.** No provenance field name appears in this source, by design and by test.
|
|
203
|
+
- **It does not verify your mapping is true** — only that it is well-shaped. See the two-tier split above.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# provenance-telemetry
|
|
2
|
+
|
|
3
|
+
**Telemetry is your provenance doctrine projected into an observability backend:** trace identity from artifact keys (never LLM-derived), user/session from your identity key, span attributes and scores from the fields your system of record already uses — so a trace and a decision record tell the same story *in the same words*, and adopters inherit the **vocabulary, not just the plumbing**.
|
|
4
|
+
|
|
5
|
+
**The dependency list *is* the adoption pitch.** Four dependencies — `langfuse`, `opentelemetry-api`, `pydantic`, `pyyaml` — one doctrine, config-governed vocabulary. This library ships **no vocabulary of its own**: which of *your* provenance fields map onto which Langfuse slot is a small ratifiable mapping config *you* supply. That is what lets any team adopt it without importing anyone else's domain — and what keeps a security review's read of this `README` + `pyproject` short.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```toml
|
|
12
|
+
# pyproject.toml — from PyPI (preferred)
|
|
13
|
+
dependencies = ["provenance-telemetry>=0.1.0,<0.2"]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```toml
|
|
17
|
+
# pyproject.toml — direct from git, pinned to a TAG (pre-index form)
|
|
18
|
+
dependencies = [
|
|
19
|
+
"provenance-telemetry @ git+https://github.com/edgy-solutions/provenance-telemetry.git@v0.1.0",
|
|
20
|
+
]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> **Never a floating ref.** A `git+https://…` with no `@ref` resolves to the default branch **at build time**, so a single upstream commit changes what every consuming service ships — with nobody deciding, and no diff in the consumer's repo to review. This is not hypothetical: a sibling package renamed its *distribution* on `master`, and every consumer floating on that branch broke on the next clean resolve, while machines holding a stale install kept working so the repo looked healthy. Pin to a tag, or to a version once it is on an index.
|
|
24
|
+
|
|
25
|
+
> **The distribution name is `provenance-telemetry`; the import name is `provenance_telemetry`.** These are different namespaces and they are allowed to diverge — which is exactly how the failure above happened, because a rename of one is invisible to code that only ever types the other. **The import name is the load-bearing one and will not change without a major version.** If the distribution is ever renamed, `import provenance_telemetry` keeps working and only your dependency *declaration* needs editing.
|
|
26
|
+
|
|
27
|
+
## Enabling it
|
|
28
|
+
|
|
29
|
+
Everything in this library is **inert unless both** of these are set:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
LANGFUSE_PUBLIC_KEY=pk-...
|
|
33
|
+
LANGFUSE_SECRET_KEY=sk-...
|
|
34
|
+
LANGFUSE_HOST=https://langfuse.example.com # read by the langfuse SDK itself
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
With either missing, every entry point becomes a pass-through: context managers yield, decorators return the undecorated function, emitters return without emitting. **No-credentials code carries no runtime dependency on Langfuse being reachable.**
|
|
38
|
+
|
|
39
|
+
> ⚠️ **`traced` gates at DECORATION time**, i.e. at module import. If you set the environment *after* importing the module that uses `@traced`, the decorator has already resolved to pass-through. This matches the deployment model (env set before the process starts) but bites in notebooks and tests. `observed_trace`, `observe_span` and `set_trace_standard` gate at **call** time and do not have this constraint.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quickstart — a traced service entry
|
|
44
|
+
|
|
45
|
+
The shape used across a real ten-engine mesh: join the caller's trace on an inbound header, enrich it from your provenance values, and let everything inside nest automatically.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from fastapi import FastAPI, Request
|
|
49
|
+
from provenance_telemetry import load_mapping, observed_trace, observe_span
|
|
50
|
+
|
|
51
|
+
MAPPING = load_mapping("config/mesh-mapping.yaml") # shape-validated at load
|
|
52
|
+
app = FastAPI()
|
|
53
|
+
|
|
54
|
+
@app.post("/analyze")
|
|
55
|
+
async def analyze(req: Request, body: dict):
|
|
56
|
+
values = {
|
|
57
|
+
"request_key": req.headers.get("X-Trace-Id"), # -> trace_id slot: THE JOIN
|
|
58
|
+
"authz_id": req.headers.get("X-Authz-Id"), # -> user_id slot
|
|
59
|
+
"session_id": body.get("session_id"),
|
|
60
|
+
"engine": "analyst",
|
|
61
|
+
"verb": "analyzeWithCodeAgent",
|
|
62
|
+
"domain": body.get("domain"),
|
|
63
|
+
}
|
|
64
|
+
with observed_trace(MAPPING, values, name="analyze"):
|
|
65
|
+
with observe_span("plan", verb=values["verb"]):
|
|
66
|
+
plan = build_plan(body)
|
|
67
|
+
return run(plan)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**What makes this a join and not a new trace:** `observed_trace` reads the field your mapping puts in the `trace_id` slot (`request_key` above) and derives a deterministic Langfuse id from it via `create_trace_id(seed=...)`. Two services handed the **same** `X-Trace-Id` compute the **same** Langfuse trace id, so their spans land on **one** trace. The join is native, not a reconciliation step.
|
|
71
|
+
|
|
72
|
+
If the seed field is absent, `observed_trace` opens a fresh trace instead — which is correct, but means **a caller that forgets the header silently gets an orphan trace rather than an error.** If joins are load-bearing for you, assert the header's presence in your own code; this library will not refuse the work to protect the telemetry.
|
|
73
|
+
|
|
74
|
+
### Propagating the trace to the next hop
|
|
75
|
+
|
|
76
|
+
The join only holds if the id travels. Send it on every outbound call:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
headers = {"X-Trace-Id": values["request_key"], "X-Authz-Id": values["authz_id"]}
|
|
80
|
+
httpx.post(next_engine_url, json=payload, headers=headers)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
### `load_mapping(source) -> Mapping`
|
|
88
|
+
|
|
89
|
+
Accepts a path or a dict. **Shape-validated at load**: an unknown Langfuse slot or score encoding raises `pydantic.ValidationError` immediately.
|
|
90
|
+
|
|
91
|
+
Two-tier validation is deliberate. This library validates **shape** — `KNOWN_SLOTS` (`trace_id`, `user_id`, `session_id`, `release`, `version`) and `KNOWN_SCORE_ENCODINGS` (`ordinal`, `fraction`, `binary_per_join`, `count_total`) are closed sets. It does **not** validate that the provenance field names on the right-hand side exist — that is **truth**, and it belongs with whoever owns the vocabulary. Add a CI check in *your* repo asserting your mapping's field names exist in your contracts; that is the half this library cannot do for you, and skipping it is how a mapping quietly projects `None` forever.
|
|
92
|
+
|
|
93
|
+
### `observed_trace(mapping, values, *, name="operation", as_type="span")`
|
|
94
|
+
|
|
95
|
+
Context manager. **The entry primitive** — opens or *joins* a trace on the mapped `trace_id`, then enriches it. Everything opened inside nests under it via the ordinary OTel context.
|
|
96
|
+
|
|
97
|
+
### `set_trace_standard(mapping, values)`
|
|
98
|
+
|
|
99
|
+
Enrich the **current** trace in place: identity slots, tags, metadata as OTel attributes, plus scores. Use when something you learned *later* in the request belongs on the trace.
|
|
100
|
+
|
|
101
|
+
**It cannot set the trace id** — under Langfuse v4 a trace's id is fixed at creation. Choose the id at the entry with `observed_trace`. Calling this with a `trace_id` slot mapped is not an error; the slot is simply skipped.
|
|
102
|
+
|
|
103
|
+
### `observe_span(operation, **attributes)` / `litellm_metadata(operation, ...)`
|
|
104
|
+
|
|
105
|
+
Two carriers for **one** span shape (both derive from `span_descriptor`), so a direct-provider call and a LiteLLM-proxied call emit comparable spans:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# direct provider — explicit span; a generation created inside nests automatically
|
|
109
|
+
with observe_span("classify", model="gpt-oss", domain=domain) as d:
|
|
110
|
+
result = client.chat.completions.create(...)
|
|
111
|
+
|
|
112
|
+
# through a LiteLLM proxy — feed the callback metadata instead
|
|
113
|
+
import litellm
|
|
114
|
+
litellm.completion(model="gpt-4o-mini", messages=msgs,
|
|
115
|
+
metadata=litellm_metadata("classify", trace_id=tid, user_id=uid,
|
|
116
|
+
session_id=sid, tags=["analyst"], domain=domain))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `traced(name=None, as_type=None)`
|
|
120
|
+
|
|
121
|
+
Decorator that opens a span/trace around a function. For a top-level entry that must open on a **chosen** id, use `observed_trace` — `traced` wraps Langfuse's `observe`, which mints its own id and therefore **cannot join**. See the decoration-time gating warning above.
|
|
122
|
+
|
|
123
|
+
### `redact(value) -> str`
|
|
124
|
+
|
|
125
|
+
**Hash, don't drop.** Returns `sha1:<16 hex>`. A redacted trace stays *joinable* — two traces referencing the same value still collide on the hash — without carrying the content. Applied automatically to any field listed in `content_bearing`; call it directly for ad-hoc values.
|
|
126
|
+
|
|
127
|
+
`is_redacted(value)` also exists but is **not** exported at top level in this release — import it as `from provenance_telemetry.redact import is_redacted`.
|
|
128
|
+
|
|
129
|
+
### `emit_misses() -> int`
|
|
130
|
+
|
|
131
|
+
**Fail-soft is only honest if it is countable.** Every soft failure — SDK absent, Langfuse unreachable, a bad score, an enrichment error — increments this counter and logs a warning; nothing raises. Telemetry must not stop the work it observes, but *silent* telemetry loss is indistinguishable from a healthy quiet system. Export it:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
@app.get("/health")
|
|
135
|
+
def health():
|
|
136
|
+
return {"status": "ok", "telemetry_misses": emit_misses()}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A rising count means you are flying blind and do not know it. Alert on the **derivative**, not the value.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## The mapping config (you supply this)
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
version: 1
|
|
147
|
+
slots: # langfuse slot -> your provenance field
|
|
148
|
+
trace_id: request_key
|
|
149
|
+
user_id: authz_id
|
|
150
|
+
session_id: session_id
|
|
151
|
+
tags: [engine, verb, domain]
|
|
152
|
+
metadata: [subject_class, resolved_via, chart_version]
|
|
153
|
+
scores: # honest-degradation signals, projected from your schema
|
|
154
|
+
confidence_tier: {encoding: ordinal}
|
|
155
|
+
needs_review: {encoding: fraction}
|
|
156
|
+
coherence: {encoding: binary_per_join}
|
|
157
|
+
crops_failed: {encoding: count_total}
|
|
158
|
+
content_bearing: [mpn, notice_id, snippet, matched_text, override_reason]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The field *names* on the right are yours. This library never names them in its source (a deletion seal enforces it) — they live only in your mapping, checked for truth by you. See [`examples/mesh-mapping.example.yaml`](examples/mesh-mapping.example.yaml).
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Recipes
|
|
166
|
+
|
|
167
|
+
- **[Durable execution (Restate, Temporal, any replaying runtime)](docs/durable-execution.md)** — the entry primitive is **not** replay-safe on its own, and the failure is invisible in a green test suite. Read this before instrumenting a durable handler.
|
|
168
|
+
|
|
169
|
+
## Non-goals
|
|
170
|
+
|
|
171
|
+
- **It will not refuse your work.** Every failure path is soft. If a trace *must* exist for compliance, that is a check you own; this library is a witness channel, not a gate.
|
|
172
|
+
- **It does not own your vocabulary.** No provenance field name appears in this source, by design and by test.
|
|
173
|
+
- **It does not verify your mapping is true** — only that it is well-shaped. See the two-tier split above.
|