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.
Files changed (25) hide show
  1. ai_release_metadata-0.1.0/PKG-INFO +169 -0
  2. ai_release_metadata-0.1.0/README.md +152 -0
  3. ai_release_metadata-0.1.0/ai_release_metadata/__init__.py +13 -0
  4. ai_release_metadata-0.1.0/ai_release_metadata/core/context.py +100 -0
  5. ai_release_metadata-0.1.0/ai_release_metadata/core/models.py +40 -0
  6. ai_release_metadata-0.1.0/ai_release_metadata/core/sdk.py +42 -0
  7. ai_release_metadata-0.1.0/ai_release_metadata/integrations/base.py +9 -0
  8. ai_release_metadata-0.1.0/ai_release_metadata/integrations/opentelemetry.py +42 -0
  9. ai_release_metadata-0.1.0/ai_release_metadata/integrations/structlog.py +12 -0
  10. ai_release_metadata-0.1.0/ai_release_metadata/plugins/__init__.py +6 -0
  11. ai_release_metadata-0.1.0/ai_release_metadata/plugins/base.py +10 -0
  12. ai_release_metadata-0.1.0/ai_release_metadata/plugins/env.py +19 -0
  13. ai_release_metadata-0.1.0/ai_release_metadata/plugins/git.py +25 -0
  14. ai_release_metadata-0.1.0/ai_release_metadata/plugins/github.py +23 -0
  15. ai_release_metadata-0.1.0/ai_release_metadata.egg-info/PKG-INFO +169 -0
  16. ai_release_metadata-0.1.0/ai_release_metadata.egg-info/SOURCES.txt +23 -0
  17. ai_release_metadata-0.1.0/ai_release_metadata.egg-info/dependency_links.txt +1 -0
  18. ai_release_metadata-0.1.0/ai_release_metadata.egg-info/requires.txt +3 -0
  19. ai_release_metadata-0.1.0/ai_release_metadata.egg-info/top_level.txt +1 -0
  20. ai_release_metadata-0.1.0/pyproject.toml +31 -0
  21. ai_release_metadata-0.1.0/setup.cfg +4 -0
  22. ai_release_metadata-0.1.0/tests/test_context.py +69 -0
  23. ai_release_metadata-0.1.0/tests/test_e2e_fastapi.py +73 -0
  24. ai_release_metadata-0.1.0/tests/test_integrations.py +61 -0
  25. 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,13 @@
1
+ from .core.context import (
2
+ release_context,
3
+ capture_generation,
4
+ get_current_context
5
+ )
6
+ from .core.sdk import MetadataProvider
7
+
8
+ __all__ = [
9
+ "release_context",
10
+ "capture_generation",
11
+ "get_current_context",
12
+ "MetadataProvider"
13
+ ]
@@ -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,6 @@
1
+ from .base import MetadataPlugin
2
+ from .env import EnvPlugin
3
+ from .git import GitPlugin
4
+ from .github import GitHubActionsPlugin
5
+
6
+ __all__ = ["MetadataPlugin", "EnvPlugin", "GitPlugin", "GitHubActionsPlugin"]
@@ -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,3 @@
1
+
2
+ [otel]
3
+ opentelemetry-api
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"