ai-release-metadata 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.
- ai_release_metadata-0.1.0/PKG-INFO +169 -0
- ai_release_metadata-0.1.0/README.md +152 -0
- ai_release_metadata-0.1.0/ai_release_metadata/__init__.py +13 -0
- ai_release_metadata-0.1.0/ai_release_metadata/core/context.py +100 -0
- ai_release_metadata-0.1.0/ai_release_metadata/core/models.py +40 -0
- ai_release_metadata-0.1.0/ai_release_metadata/core/sdk.py +42 -0
- ai_release_metadata-0.1.0/ai_release_metadata/integrations/base.py +9 -0
- ai_release_metadata-0.1.0/ai_release_metadata/integrations/opentelemetry.py +42 -0
- ai_release_metadata-0.1.0/ai_release_metadata/integrations/structlog.py +12 -0
- ai_release_metadata-0.1.0/ai_release_metadata/plugins/__init__.py +6 -0
- ai_release_metadata-0.1.0/ai_release_metadata/plugins/base.py +10 -0
- ai_release_metadata-0.1.0/ai_release_metadata/plugins/env.py +19 -0
- ai_release_metadata-0.1.0/ai_release_metadata/plugins/git.py +25 -0
- ai_release_metadata-0.1.0/ai_release_metadata/plugins/github.py +23 -0
- ai_release_metadata-0.1.0/ai_release_metadata.egg-info/PKG-INFO +169 -0
- ai_release_metadata-0.1.0/ai_release_metadata.egg-info/SOURCES.txt +23 -0
- ai_release_metadata-0.1.0/ai_release_metadata.egg-info/dependency_links.txt +1 -0
- ai_release_metadata-0.1.0/ai_release_metadata.egg-info/requires.txt +3 -0
- ai_release_metadata-0.1.0/ai_release_metadata.egg-info/top_level.txt +1 -0
- ai_release_metadata-0.1.0/pyproject.toml +31 -0
- ai_release_metadata-0.1.0/setup.cfg +4 -0
- ai_release_metadata-0.1.0/tests/test_context.py +69 -0
- ai_release_metadata-0.1.0/tests/test_e2e_fastapi.py +73 -0
- ai_release_metadata-0.1.0/tests/test_integrations.py +61 -0
- ai_release_metadata-0.1.0/tests/test_plugins.py +76 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-release-metadata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, framework-agnostic Python SDK for standardizing AI release metadata across production applications.
|
|
5
|
+
Author-email: Oura <opensource@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ai-release-metadata/ai-release-metadata
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/ai-release-metadata/ai-release-metadata/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: otel
|
|
16
|
+
Requires-Dist: opentelemetry-api; extra == "otel"
|
|
17
|
+
|
|
18
|
+
# AI Release Metadata (ai-release-metadata)
|
|
19
|
+
|
|
20
|
+
> **A lightweight SDK for standardizing and propagating operational metadata for AI-powered services through existing observability systems.**
|
|
21
|
+
|
|
22
|
+
## The Problem
|
|
23
|
+
|
|
24
|
+
When a production issue occurs in an AI-powered service (e.g., regressions, prompt injection, or unexpected model behaviour), it is often extremely difficult to quickly identify:
|
|
25
|
+
* Which prompt version generated the response?
|
|
26
|
+
* Which model was used?
|
|
27
|
+
* Which Git commit or deployment version is responsible?
|
|
28
|
+
* What experiments were active?
|
|
29
|
+
|
|
30
|
+
While existing observability platforms effectively capture execution metrics such as latency and token usage, manually correlating these spans back to the exact release configuration often results in fragmented and inconsistent metadata.
|
|
31
|
+
|
|
32
|
+
## The Solution
|
|
33
|
+
|
|
34
|
+
This SDK provides a minimal set of context managers and decorators that automatically enrich existing logs, metrics, and traces with standardized release and environment metadata.
|
|
35
|
+
|
|
36
|
+
### Architecture
|
|
37
|
+
|
|
38
|
+
```mermaid
|
|
39
|
+
flowchart TD
|
|
40
|
+
App[Application] -->|with release_context()| Provider[MetadataProvider]
|
|
41
|
+
Provider -->|extract| Plugins["Plugins (Git, Env)"]
|
|
42
|
+
Plugins --> Context(("Release Context"))
|
|
43
|
+
Context -->|get_current_context()| Exporters["Exporters"]
|
|
44
|
+
Exporters --> OTEL["OpenTelemetry / Structlog"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Design Principles
|
|
48
|
+
- **Minimal developer overhead:** Drop-in context managers and decorators.
|
|
49
|
+
- **Existing Observability First:** Integrates directly into your current logging/tracing pipelines instead of requiring a new dashboard.
|
|
50
|
+
- **A reusable building block:** This explores one reusable approach for improving traceability between production behaviour and deployed AI releases.
|
|
51
|
+
- **Framework agnostic:** Works with FastAPI, Django, or raw scripts.
|
|
52
|
+
- **Vendor agnostic:** Does not wrap or depend on specific LLM provider SDKs.
|
|
53
|
+
- **Explicit over magic:** Developers explicitly define trace boundaries.
|
|
54
|
+
- **Existing observability first:** Propagates metadata to your existing tools (like OpenTelemetry) rather than acting as a standalone backend.
|
|
55
|
+
- **Async-safe by default:** Thread-safe and async-safe context propagation.
|
|
56
|
+
|
|
57
|
+
### Features
|
|
58
|
+
* **Automatic Metadata Discovery:** Detects release and runtime information from the execution environment through a pluggable metadata system.
|
|
59
|
+
* **Application-level Instrumentation:** Instruments business operations instead of wrapping LLM SDKs, allowing the library to remain provider-agnostic.
|
|
60
|
+
* **Integrations:** Natively exports into `structlog` (JSON logs) and OpenTelemetry.
|
|
61
|
+
* **Async First:** Safe to use in high-throughput `asyncio` applications such as FastAPI.
|
|
62
|
+
|
|
63
|
+
## Use Cases
|
|
64
|
+
|
|
65
|
+
### 1. Diagnosing Latency Regressions by Git Commit
|
|
66
|
+
If latency spikes on a specific LLM feature, you can query Datadog/Grafana for all `generate` traces grouped by `ai.git_sha`. The SDK ensures this metadata is accurately attached to every span, instantly identifying the deployment that caused the regression.
|
|
67
|
+
|
|
68
|
+
### 2. A/B Testing Prompt Templates
|
|
69
|
+
When rolling out a new prompt (e.g. `prompt_version="v2.1"`), you can seamlessly track the token usage, error rates, and user feedback specifically for that variant.
|
|
70
|
+
|
|
71
|
+
## Documentation
|
|
72
|
+
|
|
73
|
+
- [Architecture](docs/architecture.md): How context propagation works under the hood.
|
|
74
|
+
- [Design Decisions](docs/design-decisions.md): The rationale behind the core architectural choices.
|
|
75
|
+
- [Metadata Schema](docs/metadata-schema.md): The full list of supported fields.
|
|
76
|
+
- [Plugin Development](docs/plugin-development.md): How to build custom discovery sources.
|
|
77
|
+
- [Exporters](docs/exporters.md): How to export data to OpenTelemetry, structlog, and more.
|
|
78
|
+
- [Roadmap](docs/roadmap.md): Future development ideas and planned integrations.
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install ai-release-metadata
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
To enable the OpenTelemetry integration automatically, install the `otel` extra:
|
|
87
|
+
```bash
|
|
88
|
+
pip install "ai-release-metadata[otel]"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
Configure the SDK once at application startup using the `MetadataProvider`.
|
|
94
|
+
|
|
95
|
+
**Plugin Precedence Rules:** The SDK resolves metadata conflicts based on the order plugins are provided. The *last* plugin evaluated takes precedence over the preceding ones. We recommend putting local plugins first, and explicit environment plugins last.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from ai_release_metadata import MetadataProvider
|
|
99
|
+
from ai_release_metadata.plugins import EnvPlugin, GitPlugin, GitHubActionsPlugin
|
|
100
|
+
|
|
101
|
+
# 1. GitPlugin tries to guess from local filesystem
|
|
102
|
+
# 2. GitHubActionsPlugin overwrites with CI variables
|
|
103
|
+
# 3. EnvPlugin overwrites with explicit user-defined env vars
|
|
104
|
+
MetadataProvider(plugins=[GitPlugin(), GitHubActionsPlugin(), EnvPlugin()])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use the context manager (`release_context`) or decorator (`@capture_generation`) within the business logic:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from ai_release_metadata import release_context, get_current_context
|
|
111
|
+
|
|
112
|
+
async def generate_response(user_id: str, query: str):
|
|
113
|
+
# Start a release context block
|
|
114
|
+
with release_context(feature="customer-support", model="gpt-4o", prompt_version="v2.1"):
|
|
115
|
+
|
|
116
|
+
# ... LLM calling logic ...
|
|
117
|
+
|
|
118
|
+
# Dynamically append runtime information to the context
|
|
119
|
+
ctx = get_current_context()
|
|
120
|
+
if ctx:
|
|
121
|
+
ctx.add_document("doc_1")
|
|
122
|
+
ctx.add_tag("user_tier", "premium")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
All logs emitted within the `with release_context(...)` block will automatically have the metadata appended under the `ai` key.
|
|
126
|
+
|
|
127
|
+
### OpenTelemetry Integration
|
|
128
|
+
|
|
129
|
+
If you use OpenTelemetry, you can automatically enrich the currently active span with the release context. It gracefully ignores spans if none are active.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from ai_release_metadata.integrations.opentelemetry import enrich_span
|
|
133
|
+
from opentelemetry import trace
|
|
134
|
+
|
|
135
|
+
tracer = trace.get_tracer(__name__)
|
|
136
|
+
|
|
137
|
+
with tracer.start_as_current_span("generate_response") as span:
|
|
138
|
+
with release_context(model="gpt-4o"):
|
|
139
|
+
# Enrich the span with git_sha, environment, model, etc.
|
|
140
|
+
enrich_span()
|
|
141
|
+
|
|
142
|
+
# ... LLM calling logic ...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Running the Demo
|
|
146
|
+
|
|
147
|
+
This repository includes a FastAPI demo simulating a generation endpoint.
|
|
148
|
+
|
|
149
|
+
1. Activate a virtual environment and install dependencies:
|
|
150
|
+
```bash
|
|
151
|
+
python3 -m venv venv
|
|
152
|
+
source venv/bin/activate
|
|
153
|
+
pip install structlog fastapi uvicorn pydantic
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
2. Run the application:
|
|
157
|
+
```bash
|
|
158
|
+
export GIT_COMMIT=a1b2c3d
|
|
159
|
+
export ENVIRONMENT=local
|
|
160
|
+
export LOG_FORMAT=console
|
|
161
|
+
PYTHONPATH=. uvicorn demo.app:app
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
3. Send a request to observe the enriched telemetry in the terminal:
|
|
165
|
+
```bash
|
|
166
|
+
curl -X POST http://127.0.0.1:8000/generate \
|
|
167
|
+
-H "Content-Type: application/json" \
|
|
168
|
+
-d '{"model": "gpt-4o", "prompt_version": "v2.0", "user_query": "I cannot login"}'
|
|
169
|
+
```
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# AI Release Metadata (ai-release-metadata)
|
|
2
|
+
|
|
3
|
+
> **A lightweight SDK for standardizing and propagating operational metadata for AI-powered services through existing observability systems.**
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
When a production issue occurs in an AI-powered service (e.g., regressions, prompt injection, or unexpected model behaviour), it is often extremely difficult to quickly identify:
|
|
8
|
+
* Which prompt version generated the response?
|
|
9
|
+
* Which model was used?
|
|
10
|
+
* Which Git commit or deployment version is responsible?
|
|
11
|
+
* What experiments were active?
|
|
12
|
+
|
|
13
|
+
While existing observability platforms effectively capture execution metrics such as latency and token usage, manually correlating these spans back to the exact release configuration often results in fragmented and inconsistent metadata.
|
|
14
|
+
|
|
15
|
+
## The Solution
|
|
16
|
+
|
|
17
|
+
This SDK provides a minimal set of context managers and decorators that automatically enrich existing logs, metrics, and traces with standardized release and environment metadata.
|
|
18
|
+
|
|
19
|
+
### Architecture
|
|
20
|
+
|
|
21
|
+
```mermaid
|
|
22
|
+
flowchart TD
|
|
23
|
+
App[Application] -->|with release_context()| Provider[MetadataProvider]
|
|
24
|
+
Provider -->|extract| Plugins["Plugins (Git, Env)"]
|
|
25
|
+
Plugins --> Context(("Release Context"))
|
|
26
|
+
Context -->|get_current_context()| Exporters["Exporters"]
|
|
27
|
+
Exporters --> OTEL["OpenTelemetry / Structlog"]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Design Principles
|
|
31
|
+
- **Minimal developer overhead:** Drop-in context managers and decorators.
|
|
32
|
+
- **Existing Observability First:** Integrates directly into your current logging/tracing pipelines instead of requiring a new dashboard.
|
|
33
|
+
- **A reusable building block:** This explores one reusable approach for improving traceability between production behaviour and deployed AI releases.
|
|
34
|
+
- **Framework agnostic:** Works with FastAPI, Django, or raw scripts.
|
|
35
|
+
- **Vendor agnostic:** Does not wrap or depend on specific LLM provider SDKs.
|
|
36
|
+
- **Explicit over magic:** Developers explicitly define trace boundaries.
|
|
37
|
+
- **Existing observability first:** Propagates metadata to your existing tools (like OpenTelemetry) rather than acting as a standalone backend.
|
|
38
|
+
- **Async-safe by default:** Thread-safe and async-safe context propagation.
|
|
39
|
+
|
|
40
|
+
### Features
|
|
41
|
+
* **Automatic Metadata Discovery:** Detects release and runtime information from the execution environment through a pluggable metadata system.
|
|
42
|
+
* **Application-level Instrumentation:** Instruments business operations instead of wrapping LLM SDKs, allowing the library to remain provider-agnostic.
|
|
43
|
+
* **Integrations:** Natively exports into `structlog` (JSON logs) and OpenTelemetry.
|
|
44
|
+
* **Async First:** Safe to use in high-throughput `asyncio` applications such as FastAPI.
|
|
45
|
+
|
|
46
|
+
## Use Cases
|
|
47
|
+
|
|
48
|
+
### 1. Diagnosing Latency Regressions by Git Commit
|
|
49
|
+
If latency spikes on a specific LLM feature, you can query Datadog/Grafana for all `generate` traces grouped by `ai.git_sha`. The SDK ensures this metadata is accurately attached to every span, instantly identifying the deployment that caused the regression.
|
|
50
|
+
|
|
51
|
+
### 2. A/B Testing Prompt Templates
|
|
52
|
+
When rolling out a new prompt (e.g. `prompt_version="v2.1"`), you can seamlessly track the token usage, error rates, and user feedback specifically for that variant.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
|
|
56
|
+
- [Architecture](docs/architecture.md): How context propagation works under the hood.
|
|
57
|
+
- [Design Decisions](docs/design-decisions.md): The rationale behind the core architectural choices.
|
|
58
|
+
- [Metadata Schema](docs/metadata-schema.md): The full list of supported fields.
|
|
59
|
+
- [Plugin Development](docs/plugin-development.md): How to build custom discovery sources.
|
|
60
|
+
- [Exporters](docs/exporters.md): How to export data to OpenTelemetry, structlog, and more.
|
|
61
|
+
- [Roadmap](docs/roadmap.md): Future development ideas and planned integrations.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install ai-release-metadata
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
To enable the OpenTelemetry integration automatically, install the `otel` extra:
|
|
70
|
+
```bash
|
|
71
|
+
pip install "ai-release-metadata[otel]"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
Configure the SDK once at application startup using the `MetadataProvider`.
|
|
77
|
+
|
|
78
|
+
**Plugin Precedence Rules:** The SDK resolves metadata conflicts based on the order plugins are provided. The *last* plugin evaluated takes precedence over the preceding ones. We recommend putting local plugins first, and explicit environment plugins last.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from ai_release_metadata import MetadataProvider
|
|
82
|
+
from ai_release_metadata.plugins import EnvPlugin, GitPlugin, GitHubActionsPlugin
|
|
83
|
+
|
|
84
|
+
# 1. GitPlugin tries to guess from local filesystem
|
|
85
|
+
# 2. GitHubActionsPlugin overwrites with CI variables
|
|
86
|
+
# 3. EnvPlugin overwrites with explicit user-defined env vars
|
|
87
|
+
MetadataProvider(plugins=[GitPlugin(), GitHubActionsPlugin(), EnvPlugin()])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use the context manager (`release_context`) or decorator (`@capture_generation`) within the business logic:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from ai_release_metadata import release_context, get_current_context
|
|
94
|
+
|
|
95
|
+
async def generate_response(user_id: str, query: str):
|
|
96
|
+
# Start a release context block
|
|
97
|
+
with release_context(feature="customer-support", model="gpt-4o", prompt_version="v2.1"):
|
|
98
|
+
|
|
99
|
+
# ... LLM calling logic ...
|
|
100
|
+
|
|
101
|
+
# Dynamically append runtime information to the context
|
|
102
|
+
ctx = get_current_context()
|
|
103
|
+
if ctx:
|
|
104
|
+
ctx.add_document("doc_1")
|
|
105
|
+
ctx.add_tag("user_tier", "premium")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
All logs emitted within the `with release_context(...)` block will automatically have the metadata appended under the `ai` key.
|
|
109
|
+
|
|
110
|
+
### OpenTelemetry Integration
|
|
111
|
+
|
|
112
|
+
If you use OpenTelemetry, you can automatically enrich the currently active span with the release context. It gracefully ignores spans if none are active.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from ai_release_metadata.integrations.opentelemetry import enrich_span
|
|
116
|
+
from opentelemetry import trace
|
|
117
|
+
|
|
118
|
+
tracer = trace.get_tracer(__name__)
|
|
119
|
+
|
|
120
|
+
with tracer.start_as_current_span("generate_response") as span:
|
|
121
|
+
with release_context(model="gpt-4o"):
|
|
122
|
+
# Enrich the span with git_sha, environment, model, etc.
|
|
123
|
+
enrich_span()
|
|
124
|
+
|
|
125
|
+
# ... LLM calling logic ...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Running the Demo
|
|
129
|
+
|
|
130
|
+
This repository includes a FastAPI demo simulating a generation endpoint.
|
|
131
|
+
|
|
132
|
+
1. Activate a virtual environment and install dependencies:
|
|
133
|
+
```bash
|
|
134
|
+
python3 -m venv venv
|
|
135
|
+
source venv/bin/activate
|
|
136
|
+
pip install structlog fastapi uvicorn pydantic
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
2. Run the application:
|
|
140
|
+
```bash
|
|
141
|
+
export GIT_COMMIT=a1b2c3d
|
|
142
|
+
export ENVIRONMENT=local
|
|
143
|
+
export LOG_FORMAT=console
|
|
144
|
+
PYTHONPATH=. uvicorn demo.app:app
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
3. Send a request to observe the enriched telemetry in the terminal:
|
|
148
|
+
```bash
|
|
149
|
+
curl -X POST http://127.0.0.1:8000/generate \
|
|
150
|
+
-H "Content-Type: application/json" \
|
|
151
|
+
-d '{"model": "gpt-4o", "prompt_version": "v2.0", "user_query": "I cannot login"}'
|
|
152
|
+
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import asyncio
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Optional, Generator, Callable, Any
|
|
6
|
+
import copy
|
|
7
|
+
|
|
8
|
+
from .sdk import MetadataProvider
|
|
9
|
+
from .models import ReleaseContext
|
|
10
|
+
|
|
11
|
+
# A thread-safe, async-safe context variable to hold the current release context
|
|
12
|
+
_current_context: contextvars.ContextVar[Optional[ReleaseContext]] = contextvars.ContextVar(
|
|
13
|
+
"ai_release_metadata", default=None
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def get_current_context() -> Optional[ReleaseContext]:
|
|
17
|
+
"""Retrieve the active AI release context metadata from the current execution."""
|
|
18
|
+
return _current_context.get()
|
|
19
|
+
|
|
20
|
+
@contextmanager
|
|
21
|
+
def release_context(
|
|
22
|
+
feature: Optional[str] = None,
|
|
23
|
+
prompt_version: Optional[str] = None,
|
|
24
|
+
model: Optional[str] = None
|
|
25
|
+
) -> Generator[ReleaseContext, None, None]:
|
|
26
|
+
"""Context manager to start a new AI release context block."""
|
|
27
|
+
parent_context = get_current_context()
|
|
28
|
+
|
|
29
|
+
if parent_context:
|
|
30
|
+
# Deep copy to prevent modifying parent state
|
|
31
|
+
metadata = copy.deepcopy(parent_context)
|
|
32
|
+
|
|
33
|
+
# Override fields if explicitly provided in this block
|
|
34
|
+
if feature:
|
|
35
|
+
metadata.feature = feature
|
|
36
|
+
if prompt_version:
|
|
37
|
+
metadata.prompt_version = prompt_version
|
|
38
|
+
if model:
|
|
39
|
+
metadata.model = model
|
|
40
|
+
else:
|
|
41
|
+
# 1. Base release metadata comes from the global MetadataProvider
|
|
42
|
+
metadata = MetadataProvider.get_global().get_base_metadata()
|
|
43
|
+
metadata.feature = feature
|
|
44
|
+
metadata.prompt_version = prompt_version
|
|
45
|
+
metadata.model = model
|
|
46
|
+
|
|
47
|
+
token = _current_context.set(metadata)
|
|
48
|
+
try:
|
|
49
|
+
yield metadata
|
|
50
|
+
finally:
|
|
51
|
+
_current_context.reset(token)
|
|
52
|
+
|
|
53
|
+
def capture_generation(
|
|
54
|
+
feature: Optional[str] = None,
|
|
55
|
+
prompt_version: Optional[str] = None,
|
|
56
|
+
model: Optional[str] = None,
|
|
57
|
+
experiment_flags: Optional[dict] = None,
|
|
58
|
+
tags: Optional[dict] = None,
|
|
59
|
+
auto_capture_args: bool = True
|
|
60
|
+
):
|
|
61
|
+
"""Decorator to wrap a function with an AI release context."""
|
|
62
|
+
import inspect
|
|
63
|
+
|
|
64
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
65
|
+
sig = inspect.signature(func)
|
|
66
|
+
|
|
67
|
+
def _capture(ctx, args, kwargs):
|
|
68
|
+
if ctx:
|
|
69
|
+
if experiment_flags:
|
|
70
|
+
ctx.experiment_flags.update(experiment_flags)
|
|
71
|
+
if tags:
|
|
72
|
+
ctx.tags.update(tags)
|
|
73
|
+
|
|
74
|
+
if auto_capture_args:
|
|
75
|
+
try:
|
|
76
|
+
bound = sig.bind(*args, **kwargs)
|
|
77
|
+
bound.apply_defaults()
|
|
78
|
+
for k, v in bound.arguments.items():
|
|
79
|
+
if isinstance(v, (str, int, float, bool)):
|
|
80
|
+
ctx.tags[f"arg_{k}"] = v
|
|
81
|
+
else:
|
|
82
|
+
ctx.tags[f"arg_{k}"] = str(v)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
if asyncio.iscoroutinefunction(func):
|
|
87
|
+
@wraps(func)
|
|
88
|
+
async def async_wrapper(*args, **kwargs):
|
|
89
|
+
with release_context(feature=feature, prompt_version=prompt_version, model=model) as ctx:
|
|
90
|
+
_capture(ctx, args, kwargs)
|
|
91
|
+
return await func(*args, **kwargs)
|
|
92
|
+
return async_wrapper
|
|
93
|
+
else:
|
|
94
|
+
@wraps(func)
|
|
95
|
+
def sync_wrapper(*args, **kwargs):
|
|
96
|
+
with release_context(feature=feature, prompt_version=prompt_version, model=model) as ctx:
|
|
97
|
+
_capture(ctx, args, kwargs)
|
|
98
|
+
return func(*args, **kwargs)
|
|
99
|
+
return sync_wrapper
|
|
100
|
+
return decorator
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import Optional, Dict, Any, List
|
|
3
|
+
|
|
4
|
+
@dataclasses.dataclass
|
|
5
|
+
class ReleaseContext:
|
|
6
|
+
"""Standardized metadata model for an AI generation trace."""
|
|
7
|
+
# Explicitly provided by developer
|
|
8
|
+
feature: Optional[str] = None
|
|
9
|
+
prompt_version: Optional[str] = None
|
|
10
|
+
model: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
# Automatically extracted
|
|
13
|
+
git_sha: Optional[str] = None
|
|
14
|
+
deployment_version: Optional[str] = None
|
|
15
|
+
environment: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
# Runtime context
|
|
18
|
+
experiment_flags: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
19
|
+
retrieved_documents: List[str] = dataclasses.field(default_factory=list)
|
|
20
|
+
tags: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
21
|
+
extra: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
def add_document(self, document: str) -> None:
|
|
24
|
+
"""Helper to append a retrieved document."""
|
|
25
|
+
self.retrieved_documents.append(document)
|
|
26
|
+
|
|
27
|
+
def add_tag(self, key: str, value: Any) -> None:
|
|
28
|
+
"""Helper to attach a runtime tag."""
|
|
29
|
+
self.tags[key] = value
|
|
30
|
+
|
|
31
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
32
|
+
"""Convert to dictionary, excluding None or empty values."""
|
|
33
|
+
result = {}
|
|
34
|
+
for k, v in dataclasses.asdict(self).items():
|
|
35
|
+
if v is not None:
|
|
36
|
+
# exclude empty dicts/lists for cleaner logs
|
|
37
|
+
if isinstance(v, (list, dict)) and not v:
|
|
38
|
+
continue
|
|
39
|
+
result[k] = v
|
|
40
|
+
return result
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import List, Optional, Dict, Any
|
|
2
|
+
import copy
|
|
3
|
+
|
|
4
|
+
from ..plugins.base import MetadataPlugin
|
|
5
|
+
from .models import ReleaseContext
|
|
6
|
+
|
|
7
|
+
class MetadataProvider:
|
|
8
|
+
"""Instanced SDK Manager. Registers itself as the global provider upon instantiation."""
|
|
9
|
+
|
|
10
|
+
_global_instance = None
|
|
11
|
+
|
|
12
|
+
def __init__(self, plugins: Optional[List[MetadataPlugin]] = None):
|
|
13
|
+
self.plugins = plugins or []
|
|
14
|
+
self.base_metadata = ReleaseContext()
|
|
15
|
+
self._evaluate_plugins()
|
|
16
|
+
|
|
17
|
+
# OpenTelemetry pattern: registering as the global instance implicitly
|
|
18
|
+
MetadataProvider._global_instance = self
|
|
19
|
+
|
|
20
|
+
def _evaluate_plugins(self):
|
|
21
|
+
"""Run all registered plugins and populate base_metadata."""
|
|
22
|
+
extra = {}
|
|
23
|
+
for plugin in self.plugins:
|
|
24
|
+
result = plugin.extract()
|
|
25
|
+
# Map known fields, dump the rest into extra
|
|
26
|
+
for k, v in result.items():
|
|
27
|
+
if hasattr(self.base_metadata, k) and k != "extra":
|
|
28
|
+
setattr(self.base_metadata, k, v)
|
|
29
|
+
else:
|
|
30
|
+
extra[k] = v
|
|
31
|
+
self.base_metadata.extra = extra
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def get_global(cls) -> "MetadataProvider":
|
|
35
|
+
"""Get the global SDK instance. If none exists, return a dummy one to avoid crashes."""
|
|
36
|
+
if cls._global_instance is None:
|
|
37
|
+
return cls()
|
|
38
|
+
return cls._global_instance
|
|
39
|
+
|
|
40
|
+
def get_base_metadata(self) -> ReleaseContext:
|
|
41
|
+
"""Return a deep copy of the base metadata for trace injection."""
|
|
42
|
+
return copy.deepcopy(self.base_metadata)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
from ..core.models import ReleaseContext
|
|
3
|
+
|
|
4
|
+
class Exporter(Protocol):
|
|
5
|
+
"""Protocol for push-based AI release metadata exporters."""
|
|
6
|
+
|
|
7
|
+
def export(self, context: ReleaseContext) -> None:
|
|
8
|
+
"""Export the unified release context to an external system."""
|
|
9
|
+
...
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from opentelemetry import trace
|
|
2
|
+
from ..core.context import get_current_context
|
|
3
|
+
|
|
4
|
+
def enrich_span(span: trace.Span = None) -> None:
|
|
5
|
+
"""
|
|
6
|
+
Appends the active AI release context to the provided OpenTelemetry span.
|
|
7
|
+
If no span is provided, it attempts to use the currently active span in the context.
|
|
8
|
+
"""
|
|
9
|
+
if span is None:
|
|
10
|
+
span = trace.get_current_span()
|
|
11
|
+
|
|
12
|
+
if not span or not span.is_recording():
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
ctx = get_current_context()
|
|
16
|
+
if not ctx:
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
# Serialize context and add attributes to the span
|
|
20
|
+
data = ctx.as_dict()
|
|
21
|
+
|
|
22
|
+
# Flatten the dict for OTEL attributes (OTEL doesn't support nested dictionaries)
|
|
23
|
+
for key, value in data.items():
|
|
24
|
+
if isinstance(value, (list, tuple)):
|
|
25
|
+
# OTEL supports homogeneous arrays of primitives
|
|
26
|
+
try:
|
|
27
|
+
span.set_attribute(f"ai.{key}", list(value))
|
|
28
|
+
except Exception:
|
|
29
|
+
span.set_attribute(f"ai.{key}", [str(v) for v in value])
|
|
30
|
+
|
|
31
|
+
elif isinstance(value, dict):
|
|
32
|
+
# Flatten one level deep (e.g. tags, experiment_flags)
|
|
33
|
+
for sub_k, sub_v in value.items():
|
|
34
|
+
if isinstance(sub_v, (str, int, float, bool)):
|
|
35
|
+
span.set_attribute(f"ai.{key}.{sub_k}", sub_v)
|
|
36
|
+
else:
|
|
37
|
+
span.set_attribute(f"ai.{key}.{sub_k}", str(sub_v))
|
|
38
|
+
|
|
39
|
+
elif isinstance(value, (str, int, float, bool)):
|
|
40
|
+
span.set_attribute(f"ai.{key}", value)
|
|
41
|
+
else:
|
|
42
|
+
span.set_attribute(f"ai.{key}", str(value))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
from ..core.context import get_current_context
|
|
3
|
+
|
|
4
|
+
def structlog_processor(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
5
|
+
"""Structlog processor that appends the active AI release metadata to the log event."""
|
|
6
|
+
context = get_current_context()
|
|
7
|
+
if context:
|
|
8
|
+
# Append release context under the 'ai' key
|
|
9
|
+
context_data = context.as_dict()
|
|
10
|
+
if context_data:
|
|
11
|
+
event_dict["ai"] = context_data
|
|
12
|
+
return event_dict
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from typing import Dict, Any, Protocol
|
|
2
|
+
|
|
3
|
+
class MetadataPlugin(Protocol):
|
|
4
|
+
"""Protocol for all AI Release metadata discovery plugins."""
|
|
5
|
+
|
|
6
|
+
def extract(self) -> Dict[str, Any]:
|
|
7
|
+
"""Extract metadata and return it as a dictionary.
|
|
8
|
+
This dictionary will be merged into the ReleaseMetadata on startup.
|
|
9
|
+
"""
|
|
10
|
+
...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from .base import MetadataPlugin
|
|
4
|
+
|
|
5
|
+
class EnvPlugin(MetadataPlugin):
|
|
6
|
+
"""Extracts metadata from environment variables."""
|
|
7
|
+
|
|
8
|
+
def extract(self) -> Dict[str, Any]:
|
|
9
|
+
metadata = {}
|
|
10
|
+
if git_sha := os.environ.get("GIT_COMMIT") or os.environ.get("GITHUB_SHA"):
|
|
11
|
+
metadata["git_sha"] = git_sha
|
|
12
|
+
|
|
13
|
+
if env := os.environ.get("ENVIRONMENT") or os.environ.get("APP_ENV"):
|
|
14
|
+
metadata["environment"] = env
|
|
15
|
+
|
|
16
|
+
if version := os.environ.get("DEPLOYMENT_VERSION") or os.environ.get("APP_VERSION"):
|
|
17
|
+
metadata["deployment_version"] = version
|
|
18
|
+
|
|
19
|
+
return metadata
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from .base import MetadataPlugin
|
|
4
|
+
|
|
5
|
+
class GitPlugin(MetadataPlugin):
|
|
6
|
+
"""Extracts metadata from the local Git repository using subprocess."""
|
|
7
|
+
|
|
8
|
+
def extract(self) -> Dict[str, Any]:
|
|
9
|
+
metadata = {}
|
|
10
|
+
try:
|
|
11
|
+
# Run git rev-parse HEAD to get the current commit SHA
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
["git", "rev-parse", "HEAD"],
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
check=True
|
|
17
|
+
)
|
|
18
|
+
sha = result.stdout.strip()
|
|
19
|
+
if sha:
|
|
20
|
+
metadata["git_sha"] = sha
|
|
21
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
22
|
+
# Fails gracefully if git is not installed or not in a git repo
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
return metadata
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from .base import MetadataPlugin
|
|
4
|
+
|
|
5
|
+
class GitHubActionsPlugin(MetadataPlugin):
|
|
6
|
+
"""Extracts metadata specific to GitHub Actions environment variables."""
|
|
7
|
+
|
|
8
|
+
def extract(self) -> Dict[str, Any]:
|
|
9
|
+
metadata = {}
|
|
10
|
+
|
|
11
|
+
# GITHUB_SHA is the commit that triggered the workflow
|
|
12
|
+
if git_sha := os.environ.get("GITHUB_SHA"):
|
|
13
|
+
metadata["git_sha"] = git_sha
|
|
14
|
+
|
|
15
|
+
# GITHUB_RUN_ID is a unique number for each workflow run, great for deployment version
|
|
16
|
+
if run_id := os.environ.get("GITHUB_RUN_ID"):
|
|
17
|
+
metadata["deployment_version"] = f"gha-{run_id}"
|
|
18
|
+
|
|
19
|
+
# GITHUB_REF_NAME is the branch or tag name
|
|
20
|
+
if ref := os.environ.get("GITHUB_REF_NAME"):
|
|
21
|
+
metadata["environment"] = ref
|
|
22
|
+
|
|
23
|
+
return metadata
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-release-metadata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, framework-agnostic Python SDK for standardizing AI release metadata across production applications.
|
|
5
|
+
Author-email: Oura <opensource@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ai-release-metadata/ai-release-metadata
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/ai-release-metadata/ai-release-metadata/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: otel
|
|
16
|
+
Requires-Dist: opentelemetry-api; extra == "otel"
|
|
17
|
+
|
|
18
|
+
# AI Release Metadata (ai-release-metadata)
|
|
19
|
+
|
|
20
|
+
> **A lightweight SDK for standardizing and propagating operational metadata for AI-powered services through existing observability systems.**
|
|
21
|
+
|
|
22
|
+
## The Problem
|
|
23
|
+
|
|
24
|
+
When a production issue occurs in an AI-powered service (e.g., regressions, prompt injection, or unexpected model behaviour), it is often extremely difficult to quickly identify:
|
|
25
|
+
* Which prompt version generated the response?
|
|
26
|
+
* Which model was used?
|
|
27
|
+
* Which Git commit or deployment version is responsible?
|
|
28
|
+
* What experiments were active?
|
|
29
|
+
|
|
30
|
+
While existing observability platforms effectively capture execution metrics such as latency and token usage, manually correlating these spans back to the exact release configuration often results in fragmented and inconsistent metadata.
|
|
31
|
+
|
|
32
|
+
## The Solution
|
|
33
|
+
|
|
34
|
+
This SDK provides a minimal set of context managers and decorators that automatically enrich existing logs, metrics, and traces with standardized release and environment metadata.
|
|
35
|
+
|
|
36
|
+
### Architecture
|
|
37
|
+
|
|
38
|
+
```mermaid
|
|
39
|
+
flowchart TD
|
|
40
|
+
App[Application] -->|with release_context()| Provider[MetadataProvider]
|
|
41
|
+
Provider -->|extract| Plugins["Plugins (Git, Env)"]
|
|
42
|
+
Plugins --> Context(("Release Context"))
|
|
43
|
+
Context -->|get_current_context()| Exporters["Exporters"]
|
|
44
|
+
Exporters --> OTEL["OpenTelemetry / Structlog"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Design Principles
|
|
48
|
+
- **Minimal developer overhead:** Drop-in context managers and decorators.
|
|
49
|
+
- **Existing Observability First:** Integrates directly into your current logging/tracing pipelines instead of requiring a new dashboard.
|
|
50
|
+
- **A reusable building block:** This explores one reusable approach for improving traceability between production behaviour and deployed AI releases.
|
|
51
|
+
- **Framework agnostic:** Works with FastAPI, Django, or raw scripts.
|
|
52
|
+
- **Vendor agnostic:** Does not wrap or depend on specific LLM provider SDKs.
|
|
53
|
+
- **Explicit over magic:** Developers explicitly define trace boundaries.
|
|
54
|
+
- **Existing observability first:** Propagates metadata to your existing tools (like OpenTelemetry) rather than acting as a standalone backend.
|
|
55
|
+
- **Async-safe by default:** Thread-safe and async-safe context propagation.
|
|
56
|
+
|
|
57
|
+
### Features
|
|
58
|
+
* **Automatic Metadata Discovery:** Detects release and runtime information from the execution environment through a pluggable metadata system.
|
|
59
|
+
* **Application-level Instrumentation:** Instruments business operations instead of wrapping LLM SDKs, allowing the library to remain provider-agnostic.
|
|
60
|
+
* **Integrations:** Natively exports into `structlog` (JSON logs) and OpenTelemetry.
|
|
61
|
+
* **Async First:** Safe to use in high-throughput `asyncio` applications such as FastAPI.
|
|
62
|
+
|
|
63
|
+
## Use Cases
|
|
64
|
+
|
|
65
|
+
### 1. Diagnosing Latency Regressions by Git Commit
|
|
66
|
+
If latency spikes on a specific LLM feature, you can query Datadog/Grafana for all `generate` traces grouped by `ai.git_sha`. The SDK ensures this metadata is accurately attached to every span, instantly identifying the deployment that caused the regression.
|
|
67
|
+
|
|
68
|
+
### 2. A/B Testing Prompt Templates
|
|
69
|
+
When rolling out a new prompt (e.g. `prompt_version="v2.1"`), you can seamlessly track the token usage, error rates, and user feedback specifically for that variant.
|
|
70
|
+
|
|
71
|
+
## Documentation
|
|
72
|
+
|
|
73
|
+
- [Architecture](docs/architecture.md): How context propagation works under the hood.
|
|
74
|
+
- [Design Decisions](docs/design-decisions.md): The rationale behind the core architectural choices.
|
|
75
|
+
- [Metadata Schema](docs/metadata-schema.md): The full list of supported fields.
|
|
76
|
+
- [Plugin Development](docs/plugin-development.md): How to build custom discovery sources.
|
|
77
|
+
- [Exporters](docs/exporters.md): How to export data to OpenTelemetry, structlog, and more.
|
|
78
|
+
- [Roadmap](docs/roadmap.md): Future development ideas and planned integrations.
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install ai-release-metadata
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
To enable the OpenTelemetry integration automatically, install the `otel` extra:
|
|
87
|
+
```bash
|
|
88
|
+
pip install "ai-release-metadata[otel]"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
Configure the SDK once at application startup using the `MetadataProvider`.
|
|
94
|
+
|
|
95
|
+
**Plugin Precedence Rules:** The SDK resolves metadata conflicts based on the order plugins are provided. The *last* plugin evaluated takes precedence over the preceding ones. We recommend putting local plugins first, and explicit environment plugins last.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from ai_release_metadata import MetadataProvider
|
|
99
|
+
from ai_release_metadata.plugins import EnvPlugin, GitPlugin, GitHubActionsPlugin
|
|
100
|
+
|
|
101
|
+
# 1. GitPlugin tries to guess from local filesystem
|
|
102
|
+
# 2. GitHubActionsPlugin overwrites with CI variables
|
|
103
|
+
# 3. EnvPlugin overwrites with explicit user-defined env vars
|
|
104
|
+
MetadataProvider(plugins=[GitPlugin(), GitHubActionsPlugin(), EnvPlugin()])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use the context manager (`release_context`) or decorator (`@capture_generation`) within the business logic:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from ai_release_metadata import release_context, get_current_context
|
|
111
|
+
|
|
112
|
+
async def generate_response(user_id: str, query: str):
|
|
113
|
+
# Start a release context block
|
|
114
|
+
with release_context(feature="customer-support", model="gpt-4o", prompt_version="v2.1"):
|
|
115
|
+
|
|
116
|
+
# ... LLM calling logic ...
|
|
117
|
+
|
|
118
|
+
# Dynamically append runtime information to the context
|
|
119
|
+
ctx = get_current_context()
|
|
120
|
+
if ctx:
|
|
121
|
+
ctx.add_document("doc_1")
|
|
122
|
+
ctx.add_tag("user_tier", "premium")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
All logs emitted within the `with release_context(...)` block will automatically have the metadata appended under the `ai` key.
|
|
126
|
+
|
|
127
|
+
### OpenTelemetry Integration
|
|
128
|
+
|
|
129
|
+
If you use OpenTelemetry, you can automatically enrich the currently active span with the release context. It gracefully ignores spans if none are active.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from ai_release_metadata.integrations.opentelemetry import enrich_span
|
|
133
|
+
from opentelemetry import trace
|
|
134
|
+
|
|
135
|
+
tracer = trace.get_tracer(__name__)
|
|
136
|
+
|
|
137
|
+
with tracer.start_as_current_span("generate_response") as span:
|
|
138
|
+
with release_context(model="gpt-4o"):
|
|
139
|
+
# Enrich the span with git_sha, environment, model, etc.
|
|
140
|
+
enrich_span()
|
|
141
|
+
|
|
142
|
+
# ... LLM calling logic ...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Running the Demo
|
|
146
|
+
|
|
147
|
+
This repository includes a FastAPI demo simulating a generation endpoint.
|
|
148
|
+
|
|
149
|
+
1. Activate a virtual environment and install dependencies:
|
|
150
|
+
```bash
|
|
151
|
+
python3 -m venv venv
|
|
152
|
+
source venv/bin/activate
|
|
153
|
+
pip install structlog fastapi uvicorn pydantic
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
2. Run the application:
|
|
157
|
+
```bash
|
|
158
|
+
export GIT_COMMIT=a1b2c3d
|
|
159
|
+
export ENVIRONMENT=local
|
|
160
|
+
export LOG_FORMAT=console
|
|
161
|
+
PYTHONPATH=. uvicorn demo.app:app
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
3. Send a request to observe the enriched telemetry in the terminal:
|
|
165
|
+
```bash
|
|
166
|
+
curl -X POST http://127.0.0.1:8000/generate \
|
|
167
|
+
-H "Content-Type: application/json" \
|
|
168
|
+
-d '{"model": "gpt-4o", "prompt_version": "v2.0", "user_query": "I cannot login"}'
|
|
169
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ai_release_metadata/__init__.py
|
|
4
|
+
ai_release_metadata.egg-info/PKG-INFO
|
|
5
|
+
ai_release_metadata.egg-info/SOURCES.txt
|
|
6
|
+
ai_release_metadata.egg-info/dependency_links.txt
|
|
7
|
+
ai_release_metadata.egg-info/requires.txt
|
|
8
|
+
ai_release_metadata.egg-info/top_level.txt
|
|
9
|
+
ai_release_metadata/core/context.py
|
|
10
|
+
ai_release_metadata/core/models.py
|
|
11
|
+
ai_release_metadata/core/sdk.py
|
|
12
|
+
ai_release_metadata/integrations/base.py
|
|
13
|
+
ai_release_metadata/integrations/opentelemetry.py
|
|
14
|
+
ai_release_metadata/integrations/structlog.py
|
|
15
|
+
ai_release_metadata/plugins/__init__.py
|
|
16
|
+
ai_release_metadata/plugins/base.py
|
|
17
|
+
ai_release_metadata/plugins/env.py
|
|
18
|
+
ai_release_metadata/plugins/git.py
|
|
19
|
+
ai_release_metadata/plugins/github.py
|
|
20
|
+
tests/test_context.py
|
|
21
|
+
tests/test_e2e_fastapi.py
|
|
22
|
+
tests/test_integrations.py
|
|
23
|
+
tests/test_plugins.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_release_metadata
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ai-release-metadata"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Oura", email="opensource@example.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A lightweight, framework-agnostic Python SDK for standardizing AI release metadata across production applications."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.7"
|
|
14
|
+
license = { text = "MIT" }
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
otel = ["opentelemetry-api"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
"Homepage" = "https://github.com/ai-release-metadata/ai-release-metadata"
|
|
28
|
+
"Bug Tracker" = "https://github.com/ai-release-metadata/ai-release-metadata/issues"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
include = ["ai_release_metadata*"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pytest
|
|
3
|
+
from ai_release_metadata.core.context import release_context, capture_generation, get_current_context
|
|
4
|
+
|
|
5
|
+
def test_release_context_manager():
|
|
6
|
+
# Outside context, should be None
|
|
7
|
+
assert get_current_context() is None
|
|
8
|
+
|
|
9
|
+
with release_context(feature="test-feature", prompt_version="v1"):
|
|
10
|
+
ctx = get_current_context()
|
|
11
|
+
assert ctx is not None
|
|
12
|
+
assert ctx.feature == "test-feature"
|
|
13
|
+
assert ctx.prompt_version == "v1"
|
|
14
|
+
assert ctx.model is None
|
|
15
|
+
|
|
16
|
+
# Test updating runtime context
|
|
17
|
+
ctx.retrieved_documents.append("doc-1")
|
|
18
|
+
assert get_current_context().retrieved_documents == ["doc-1"]
|
|
19
|
+
|
|
20
|
+
# After context, should reset to None
|
|
21
|
+
assert get_current_context() is None
|
|
22
|
+
|
|
23
|
+
def test_capture_generation_decorator_sync():
|
|
24
|
+
@capture_generation(feature="decorated-feature")
|
|
25
|
+
def sync_dummy():
|
|
26
|
+
ctx = get_current_context()
|
|
27
|
+
return ctx.feature if ctx else None
|
|
28
|
+
|
|
29
|
+
assert get_current_context() is None
|
|
30
|
+
assert sync_dummy() == "decorated-feature"
|
|
31
|
+
assert get_current_context() is None
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_capture_generation_decorator_async():
|
|
35
|
+
@capture_generation(model="gpt-4o")
|
|
36
|
+
async def async_dummy():
|
|
37
|
+
ctx = get_current_context()
|
|
38
|
+
# Simulate some async work
|
|
39
|
+
await asyncio.sleep(0.01)
|
|
40
|
+
return ctx.model if ctx else None
|
|
41
|
+
|
|
42
|
+
assert get_current_context() is None
|
|
43
|
+
assert await async_dummy() == "gpt-4o"
|
|
44
|
+
assert get_current_context() is None
|
|
45
|
+
|
|
46
|
+
def test_nested_release_context():
|
|
47
|
+
with release_context(feature="parent", model="gpt-4"):
|
|
48
|
+
ctx = get_current_context()
|
|
49
|
+
ctx.tags["user_tier"] = "premium"
|
|
50
|
+
ctx.experiment_flags["new_rag"] = True
|
|
51
|
+
|
|
52
|
+
with release_context(feature="child", prompt_version="v2"):
|
|
53
|
+
child_ctx = get_current_context()
|
|
54
|
+
# Inherited tags/experiments
|
|
55
|
+
assert child_ctx.tags["user_tier"] == "premium"
|
|
56
|
+
assert child_ctx.experiment_flags["new_rag"] is True
|
|
57
|
+
# Overridden properties
|
|
58
|
+
assert child_ctx.feature == "child"
|
|
59
|
+
assert child_ctx.prompt_version == "v2"
|
|
60
|
+
# Inherited model from parent because it wasn't specified in child
|
|
61
|
+
assert child_ctx.model == "gpt-4"
|
|
62
|
+
|
|
63
|
+
# Modifying child shouldn't modify parent due to copy()
|
|
64
|
+
child_ctx.tags["child_specific"] = "yes"
|
|
65
|
+
|
|
66
|
+
# Back to parent
|
|
67
|
+
parent_restored = get_current_context()
|
|
68
|
+
assert parent_restored.feature == "parent"
|
|
69
|
+
assert "child_specific" not in parent_restored.tags
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi.testclient import TestClient
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def e2e_env(monkeypatch):
|
|
8
|
+
"""Sets up the environment exactly as it would be in a CI/CD pipeline."""
|
|
9
|
+
monkeypatch.setenv("GIT_COMMIT", "e2e-test-sha")
|
|
10
|
+
monkeypatch.setenv("ENVIRONMENT", "e2e")
|
|
11
|
+
monkeypatch.setenv("LOG_FORMAT", "json") # Ensure structlog outputs raw JSON for parsing
|
|
12
|
+
|
|
13
|
+
# Reset the global metadata provider so the new environment variables take effect
|
|
14
|
+
from ai_release_metadata.core.sdk import MetadataProvider
|
|
15
|
+
MetadataProvider._global_instance = None
|
|
16
|
+
|
|
17
|
+
def test_fastapi_e2e_flow(e2e_env, caplog):
|
|
18
|
+
import logging
|
|
19
|
+
caplog.set_level(logging.INFO)
|
|
20
|
+
"""
|
|
21
|
+
True E2E test:
|
|
22
|
+
1. Loads the FastAPI app
|
|
23
|
+
2. Sends a real HTTP request
|
|
24
|
+
3. Intercepts the terminal output
|
|
25
|
+
4. Asserts that the 'ai' metadata survived the async stack and printed correctly
|
|
26
|
+
"""
|
|
27
|
+
# Import inside the test so environment variables are applied during module load
|
|
28
|
+
import demo.app
|
|
29
|
+
import importlib
|
|
30
|
+
|
|
31
|
+
# Force reload in case other tests imported it first
|
|
32
|
+
importlib.reload(demo.app)
|
|
33
|
+
|
|
34
|
+
client = TestClient(demo.app.app)
|
|
35
|
+
|
|
36
|
+
# Send a request to the server
|
|
37
|
+
response = client.post(
|
|
38
|
+
"/generate",
|
|
39
|
+
json={
|
|
40
|
+
"model": "gpt-4o",
|
|
41
|
+
"prompt_version": "v1.0",
|
|
42
|
+
"user_query": "hello"
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
assert response.status_code == 200
|
|
47
|
+
|
|
48
|
+
# Capture the structlog output pushed to standard logging
|
|
49
|
+
# Structlog serializes the dictionary into a JSON string in record.message
|
|
50
|
+
found_target_log = False
|
|
51
|
+
|
|
52
|
+
for record in caplog.records:
|
|
53
|
+
try:
|
|
54
|
+
log_data = json.loads(record.message)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if log_data.get("message") == "Deep research complete" or log_data.get("event") == "Deep research complete":
|
|
59
|
+
found_target_log = True
|
|
60
|
+
ai_meta = log_data.get("ai", {})
|
|
61
|
+
|
|
62
|
+
# Assert Global Context propagated (from EnvPlugin)
|
|
63
|
+
assert ai_meta.get("git_sha") == "e2e-test-sha"
|
|
64
|
+
|
|
65
|
+
# Assert Top-Level Request Context propagated (from app.post)
|
|
66
|
+
assert ai_meta.get("model") == "gpt-4o"
|
|
67
|
+
assert ai_meta.get("prompt_version") == "v1.0"
|
|
68
|
+
|
|
69
|
+
# Assert Decorator Context propagated (from @capture_generation)
|
|
70
|
+
assert ai_meta.get("feature") == "comprehensive-answer"
|
|
71
|
+
assert ai_meta.get("experiment_flags", {}).get("use_deep_research") is True
|
|
72
|
+
|
|
73
|
+
assert found_target_log, f"Did not find the expected log event. Captured messages: {[r.message for r in caplog.records]}"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from opentelemetry import trace
|
|
3
|
+
from ai_release_metadata.core.context import release_context
|
|
4
|
+
from ai_release_metadata.integrations.opentelemetry import enrich_span
|
|
5
|
+
|
|
6
|
+
class MockSpan(trace.Span):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.attributes = {}
|
|
9
|
+
self._is_recording = True
|
|
10
|
+
|
|
11
|
+
def set_attribute(self, key: str, value) -> None:
|
|
12
|
+
self.attributes[key] = value
|
|
13
|
+
|
|
14
|
+
def set_attributes(self, attributes) -> None:
|
|
15
|
+
for k, v in attributes.items():
|
|
16
|
+
self.attributes[k] = v
|
|
17
|
+
|
|
18
|
+
def is_recording(self) -> bool:
|
|
19
|
+
return self._is_recording
|
|
20
|
+
|
|
21
|
+
def end(self, end_time=None) -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def get_span_context(self) -> trace.SpanContext:
|
|
25
|
+
return trace.SpanContext(trace_id=1, span_id=1, is_remote=False)
|
|
26
|
+
|
|
27
|
+
def set_status(self, status, description=None) -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def update_name(self, name) -> None:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def record_exception(self, exception, attributes=None, timestamp=None, escaped=False) -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def add_event(self, name, attributes=None, timestamp=None) -> None:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def add_link(self, context, attributes=None) -> None:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def test_otel_enrich_span():
|
|
43
|
+
span = MockSpan()
|
|
44
|
+
|
|
45
|
+
with release_context(feature="search", model="gpt-4o") as ctx:
|
|
46
|
+
ctx.git_sha = "abc1234"
|
|
47
|
+
ctx.tags["user_type"] = "admin"
|
|
48
|
+
ctx.retrieved_documents = ["docA", "docB"]
|
|
49
|
+
|
|
50
|
+
enrich_span(span)
|
|
51
|
+
|
|
52
|
+
assert span.attributes["ai.feature"] == "search"
|
|
53
|
+
assert span.attributes["ai.model"] == "gpt-4o"
|
|
54
|
+
assert span.attributes["ai.git_sha"] == "abc1234"
|
|
55
|
+
assert span.attributes["ai.tags.user_type"] == "admin"
|
|
56
|
+
assert span.attributes["ai.retrieved_documents"] == ["docA", "docB"]
|
|
57
|
+
|
|
58
|
+
def test_otel_enrich_span_no_active_context():
|
|
59
|
+
span = MockSpan()
|
|
60
|
+
enrich_span(span)
|
|
61
|
+
assert not span.attributes # Should do nothing without crashing
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from ai_release_metadata.plugins.env import EnvPlugin
|
|
3
|
+
|
|
4
|
+
def test_env_plugin():
|
|
5
|
+
os.environ["GIT_COMMIT"] = "abcdef"
|
|
6
|
+
os.environ["ENVIRONMENT"] = "staging"
|
|
7
|
+
|
|
8
|
+
extractor = EnvPlugin()
|
|
9
|
+
data = extractor.extract()
|
|
10
|
+
assert data["git_sha"] == "abcdef"
|
|
11
|
+
assert data["environment"] == "staging"
|
|
12
|
+
|
|
13
|
+
def test_config_merging():
|
|
14
|
+
os.environ["GIT_COMMIT"] = "123456"
|
|
15
|
+
from ai_release_metadata.core.sdk import MetadataProvider
|
|
16
|
+
MetadataProvider(plugins=[EnvPlugin()])
|
|
17
|
+
|
|
18
|
+
# Re-evaluate logic to ensure context propagation still works under new config
|
|
19
|
+
from ai_release_metadata.core.context import release_context, get_current_context
|
|
20
|
+
with release_context(feature="demo"):
|
|
21
|
+
ctx = get_current_context()
|
|
22
|
+
assert ctx.feature == "demo"
|
|
23
|
+
assert ctx.git_sha == "123456"
|
|
24
|
+
|
|
25
|
+
def test_github_actions_plugin(monkeypatch):
|
|
26
|
+
monkeypatch.setenv("GITHUB_SHA", "gha123")
|
|
27
|
+
monkeypatch.setenv("GITHUB_RUN_ID", "9876")
|
|
28
|
+
monkeypatch.setenv("GITHUB_REF_NAME", "main")
|
|
29
|
+
|
|
30
|
+
from ai_release_metadata.plugins.github import GitHubActionsPlugin
|
|
31
|
+
plugin = GitHubActionsPlugin()
|
|
32
|
+
data = plugin.extract()
|
|
33
|
+
|
|
34
|
+
assert data["git_sha"] == "gha123"
|
|
35
|
+
assert data["deployment_version"] == "gha-9876"
|
|
36
|
+
assert data["environment"] == "main"
|
|
37
|
+
|
|
38
|
+
def test_git_plugin_mocked(monkeypatch):
|
|
39
|
+
import subprocess
|
|
40
|
+
|
|
41
|
+
# Mock subprocess.run to simulate a valid git repo
|
|
42
|
+
def mock_run(*args, **kwargs):
|
|
43
|
+
class MockResult:
|
|
44
|
+
stdout = "mock-sha-890\n"
|
|
45
|
+
return MockResult()
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(subprocess, "run", mock_run)
|
|
48
|
+
|
|
49
|
+
from ai_release_metadata.plugins.git import GitPlugin
|
|
50
|
+
plugin = GitPlugin()
|
|
51
|
+
data = plugin.extract()
|
|
52
|
+
|
|
53
|
+
assert data["git_sha"] == "mock-sha-890"
|
|
54
|
+
|
|
55
|
+
def test_custom_plugin_extra_fields():
|
|
56
|
+
from typing import Dict, Any
|
|
57
|
+
|
|
58
|
+
class CustomCompanyPlugin:
|
|
59
|
+
def extract(self) -> Dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"git_sha": "custom123",
|
|
62
|
+
"company_org": "finance",
|
|
63
|
+
"cost_center": "4455"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
from ai_release_metadata.core.sdk import MetadataProvider
|
|
67
|
+
provider = MetadataProvider(plugins=[CustomCompanyPlugin()])
|
|
68
|
+
|
|
69
|
+
base_meta = provider.get_base_metadata()
|
|
70
|
+
|
|
71
|
+
# Core field should be mapped
|
|
72
|
+
assert base_meta.git_sha == "custom123"
|
|
73
|
+
|
|
74
|
+
# Unknown fields should be dumped into 'extra'
|
|
75
|
+
assert base_meta.extra["company_org"] == "finance"
|
|
76
|
+
assert base_meta.extra["cost_center"] == "4455"
|