nirizan 0.1.0__py3-none-any.whl
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.
- nirizan/__init__.py +18 -0
- nirizan/_logging.py +113 -0
- nirizan/gate/__init__.py +18 -0
- nirizan/gate/ci.py +80 -0
- nirizan/gate/verdict.py +170 -0
- nirizan/instrumentation/__init__.py +6 -0
- nirizan/instrumentation/exporters.py +47 -0
- nirizan/instrumentation/sdk.py +117 -0
- nirizan/instrumentation/sessions.py +18 -0
- nirizan/instrumentation/spans.py +62 -0
- nirizan/instrumentation/tracer.py +132 -0
- nirizan/metrics/__init__.py +6 -0
- nirizan/metrics/base.py +38 -0
- nirizan/metrics/behavioral_anchor.py +71 -0
- nirizan/metrics/lightweight_judge.py +87 -0
- nirizan/metrics/llm_judge.py +89 -0
- nirizan/metrics/rag_triad.py +107 -0
- nirizan/metrics/statistical_gating.py +203 -0
- nirizan/orchestrator/__init__.py +5 -0
- nirizan/orchestrator/collector.py +98 -0
- nirizan/orchestrator/dispatcher.py +23 -0
- nirizan/orchestrator/scheduler.py +76 -0
- nirizan/regression/__init__.py +22 -0
- nirizan/regression/comparator.py +275 -0
- nirizan/regression/thresholds.py +113 -0
- nirizan/reporting/__init__.py +5 -0
- nirizan/reporting/dashboard.py +113 -0
- nirizan/reporting/health_score.py +22 -0
- nirizan/reporting/judge_reliability.py +176 -0
- nirizan/storage/__init__.py +4 -0
- nirizan/storage/baselines.py +100 -0
- nirizan/storage/experiment_store.py +122 -0
- nirizan/storage/models.py +115 -0
- nirizan/storage/run_repository.py +26 -0
- nirizan/storage/session_repository.py +26 -0
- nirizan/storage/trace_repository.py +252 -0
- nirizan/trust/__init__.py +5 -0
- nirizan/trust/anchor_set.py +22 -0
- nirizan/trust/attribution.py +67 -0
- nirizan-0.1.0.dist-info/METADATA +108 -0
- nirizan-0.1.0.dist-info/RECORD +43 -0
- nirizan-0.1.0.dist-info/WHEEL +4 -0
- nirizan-0.1.0.dist-info/licenses/LICENSE +674 -0
nirizan/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# src/nirizan/__init__.py
|
|
2
|
+
"""NiriZan: Continuous evaluation infrastructure for production AI systems."""
|
|
3
|
+
from nirizan._logging import (
|
|
4
|
+
disable_logging,
|
|
5
|
+
enable_logging,
|
|
6
|
+
get_logger,
|
|
7
|
+
set_log_level,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"disable_logging",
|
|
15
|
+
"enable_logging",
|
|
16
|
+
"get_logger",
|
|
17
|
+
"set_log_level",
|
|
18
|
+
]
|
nirizan/_logging.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# src/nirizan/_logging.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TextIO, Union
|
|
8
|
+
|
|
9
|
+
_ROOT_LOGGER_NAME = "nirizan"
|
|
10
|
+
_ENV_VAR_LOG_LEVEL = "NIRIZAN_LOG_LEVEL"
|
|
11
|
+
|
|
12
|
+
LogLevel = Union[int, str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _NiriZanStreamHandler(logging.StreamHandler[TextIO]):
|
|
16
|
+
"""Internal StreamHandler subclass used to identify NiriZan-managed handlers."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Library-safe default: silence by default, let the host app opt in.
|
|
20
|
+
logging.getLogger(_ROOT_LOGGER_NAME).addHandler(logging.NullHandler())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_logger(module_name: str) -> logging.Logger:
|
|
24
|
+
"""Return a logger scoped under the `nirizan` hierarchy.
|
|
25
|
+
|
|
26
|
+
Call as `get_logger(__name__)` from any module inside `nirizan/`.
|
|
27
|
+
"""
|
|
28
|
+
return logging.getLogger(module_name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NiriZanFormatter(logging.Formatter):
|
|
32
|
+
"""Formats a record with millisecond precision and full exception trace support:
|
|
33
|
+
|
|
34
|
+
[INFO] 2026-08-07 14:40:23.279 [NiriZan] tracer.py:182 Started trace 4baf5d17
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
38
|
+
ts = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
|
|
39
|
+
ts = f"{ts}.{int(record.msecs):03d}"
|
|
40
|
+
|
|
41
|
+
msg = (
|
|
42
|
+
f"[{record.levelname}] {ts} [NiriZan] "
|
|
43
|
+
f"{record.filename}:{record.lineno} {record.getMessage()}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if record.exc_info:
|
|
47
|
+
if not record.exc_text:
|
|
48
|
+
record.exc_text = self.formatException(record.exc_info)
|
|
49
|
+
msg = f"{msg}\n{record.exc_text}"
|
|
50
|
+
|
|
51
|
+
if record.stack_info:
|
|
52
|
+
msg = f"{msg}\n{self.formatStack(record.stack_info)}"
|
|
53
|
+
|
|
54
|
+
return msg
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_level(level: LogLevel | None) -> int:
|
|
58
|
+
"""Parse int, string, or environment variable into a valid logging level."""
|
|
59
|
+
if level is None:
|
|
60
|
+
env_val = os.getenv(_ENV_VAR_LOG_LEVEL, "INFO").upper()
|
|
61
|
+
return getattr(logging, env_val, logging.INFO)
|
|
62
|
+
|
|
63
|
+
if isinstance(level, str):
|
|
64
|
+
numeric_level = getattr(logging, level.upper(), None)
|
|
65
|
+
if not isinstance(numeric_level, int):
|
|
66
|
+
raise ValueError(f"Invalid log level string: {level!r}")
|
|
67
|
+
return numeric_level
|
|
68
|
+
|
|
69
|
+
return level
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def enable_logging(
|
|
73
|
+
level: LogLevel | None = None,
|
|
74
|
+
stream: TextIO | None = None,
|
|
75
|
+
) -> logging.Logger:
|
|
76
|
+
"""Opt-in logging configuration for notebooks, CLI runs, or host apps.
|
|
77
|
+
|
|
78
|
+
Accepts both string ("DEBUG", "INFO") and integer (logging.INFO) levels.
|
|
79
|
+
If no level is supplied, checks the `NIRIZAN_LOG_LEVEL` environment variable,
|
|
80
|
+
defaulting to `INFO`.
|
|
81
|
+
|
|
82
|
+
Idempotent: replaces only NiriZan-managed handlers without touching
|
|
83
|
+
handlers attached by host applications.
|
|
84
|
+
"""
|
|
85
|
+
target_level = _parse_level(level)
|
|
86
|
+
root = logging.getLogger(_ROOT_LOGGER_NAME)
|
|
87
|
+
root.setLevel(target_level)
|
|
88
|
+
|
|
89
|
+
# Clean up prior NiriZan-managed handlers
|
|
90
|
+
for existing in list(root.handlers):
|
|
91
|
+
if isinstance(existing, _NiriZanStreamHandler):
|
|
92
|
+
root.removeHandler(existing)
|
|
93
|
+
|
|
94
|
+
handler = _NiriZanStreamHandler(stream or sys.stderr)
|
|
95
|
+
handler.setFormatter(NiriZanFormatter())
|
|
96
|
+
root.addHandler(handler)
|
|
97
|
+
|
|
98
|
+
return root
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def set_log_level(level: LogLevel) -> None:
|
|
102
|
+
"""Dynamically update log level for all NiriZan loggers without re-attaching handlers."""
|
|
103
|
+
target_level = _parse_level(level)
|
|
104
|
+
root = logging.getLogger(_ROOT_LOGGER_NAME)
|
|
105
|
+
root.setLevel(target_level)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def disable_logging() -> None:
|
|
109
|
+
"""Remove all NiriZan-managed handlers and silence output."""
|
|
110
|
+
root = logging.getLogger(_ROOT_LOGGER_NAME)
|
|
111
|
+
for existing in list(root.handlers):
|
|
112
|
+
if isinstance(existing, _NiriZanStreamHandler):
|
|
113
|
+
root.removeHandler(existing)
|
nirizan/gate/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Deployment-aware CI/CD gating layer.
|
|
2
|
+
|
|
3
|
+
Evaluates regression verdicts alongside statistical confidence intervals to produce
|
|
4
|
+
release deployment signals.
|
|
5
|
+
"""
|
|
6
|
+
# src/nirizan/gate/__init__.py
|
|
7
|
+
|
|
8
|
+
from nirizan.gate.verdict import (
|
|
9
|
+
GateVerdict,
|
|
10
|
+
evaluate_gate,
|
|
11
|
+
select_decision_metric,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"GateVerdict",
|
|
16
|
+
"evaluate_gate",
|
|
17
|
+
"select_decision_metric",
|
|
18
|
+
]
|
nirizan/gate/ci.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# src/nirizan/gate/ci.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from nirizan._logging import get_logger
|
|
8
|
+
from nirizan.gate.verdict import GateVerdict
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_gate_summary(verdict: GateVerdict) -> str:
|
|
14
|
+
lines = [
|
|
15
|
+
"| Metric | Severity | P-Value | Effect Size |",
|
|
16
|
+
"|---|---|---:|---:|",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
for regression in verdict.regression_verdicts:
|
|
20
|
+
p_value = (
|
|
21
|
+
f"{regression.p_value:.4e}"
|
|
22
|
+
if regression.p_value is not None
|
|
23
|
+
else "n/a"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
effect_size = (
|
|
27
|
+
f"{regression.effect_size:.3f}"
|
|
28
|
+
if regression.effect_size is not None
|
|
29
|
+
else "n/a"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
lines.append(
|
|
33
|
+
f"| {regression.metric_name} "
|
|
34
|
+
f"| {regression.severity.value} "
|
|
35
|
+
f"| {p_value} "
|
|
36
|
+
f"| {effect_size} |"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
lines.append("")
|
|
40
|
+
lines.append(
|
|
41
|
+
f"**Gate:** {'PASS' if verdict.passed else 'BLOCK'}"
|
|
42
|
+
)
|
|
43
|
+
lines.append(
|
|
44
|
+
"**95% bootstrap CI:** "
|
|
45
|
+
f"`{verdict.confidence_interval[0]:.6f}, "
|
|
46
|
+
f"{verdict.confidence_interval[1]:.6f}`"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_github_summary(
|
|
53
|
+
verdict: GateVerdict,
|
|
54
|
+
*,
|
|
55
|
+
output: TextIO,
|
|
56
|
+
) -> None:
|
|
57
|
+
logger.info(
|
|
58
|
+
"Writing GitHub CI summary for run_id=%s (passed=%s)",
|
|
59
|
+
verdict.run_id,
|
|
60
|
+
verdict.passed,
|
|
61
|
+
)
|
|
62
|
+
output.write(format_gate_summary(verdict))
|
|
63
|
+
output.write("\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def gate_exit_code(verdict: GateVerdict) -> int:
|
|
67
|
+
if verdict.passed:
|
|
68
|
+
logger.info("CI Gate PASSED for run_id=%s", verdict.run_id)
|
|
69
|
+
return 0
|
|
70
|
+
else:
|
|
71
|
+
logger.error("CI Gate BLOCKED for run_id=%s", verdict.run_id)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def serialize_gate_verdict(verdict: GateVerdict) -> str:
|
|
76
|
+
logger.debug("Serializing GateVerdict for run_id=%s", verdict.run_id)
|
|
77
|
+
return json.dumps(
|
|
78
|
+
verdict.model_dump(mode="json"),
|
|
79
|
+
indent=2,
|
|
80
|
+
)
|
nirizan/gate/verdict.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# src/nirizan/gate/verdict.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from nirizan._logging import get_logger
|
|
10
|
+
from nirizan.regression.comparator import (
|
|
11
|
+
RegressionSeverity,
|
|
12
|
+
RegressionVerdict,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GateVerdict(BaseModel):
|
|
19
|
+
model_config = ConfigDict(strict=True)
|
|
20
|
+
|
|
21
|
+
passed: bool
|
|
22
|
+
confidence_interval: tuple[float, float]
|
|
23
|
+
regression_verdicts: list[RegressionVerdict] = Field(
|
|
24
|
+
default_factory=list
|
|
25
|
+
)
|
|
26
|
+
run_id: UUID
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SEVERITY_WEIGHT = {
|
|
30
|
+
RegressionSeverity.BLOCKING: 3,
|
|
31
|
+
RegressionSeverity.WARNING: 2,
|
|
32
|
+
RegressionSeverity.NONE: 1,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bootstrap_delta_ci(
|
|
37
|
+
candidate: np.ndarray,
|
|
38
|
+
baseline: np.ndarray,
|
|
39
|
+
*,
|
|
40
|
+
n_bootstrap: int = 5000,
|
|
41
|
+
confidence: float = 0.95,
|
|
42
|
+
seed: int = 42,
|
|
43
|
+
) -> tuple[float, float]:
|
|
44
|
+
if candidate.size == 0 or baseline.size == 0:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"Both distributions must contain observations."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if n_bootstrap < 1:
|
|
50
|
+
raise ValueError("n_bootstrap must be positive.")
|
|
51
|
+
|
|
52
|
+
if not 0.0 < confidence < 1.0:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"confidence must be between 0 and 1."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
logger.debug(
|
|
58
|
+
"Computing bootstrap delta CI: n_bootstrap=%d, confidence=%.2f, candidate_n=%d, baseline_n=%d",
|
|
59
|
+
n_bootstrap,
|
|
60
|
+
confidence,
|
|
61
|
+
candidate.size,
|
|
62
|
+
baseline.size,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
rng = np.random.default_rng(seed)
|
|
66
|
+
|
|
67
|
+
candidate_samples = rng.choice(
|
|
68
|
+
candidate,
|
|
69
|
+
size=(n_bootstrap, candidate.size),
|
|
70
|
+
replace=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
baseline_samples = rng.choice(
|
|
74
|
+
baseline,
|
|
75
|
+
size=(n_bootstrap, baseline.size),
|
|
76
|
+
replace=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
deltas = (
|
|
80
|
+
candidate_samples.mean(axis=1)
|
|
81
|
+
- baseline_samples.mean(axis=1)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
alpha = 1.0 - confidence
|
|
85
|
+
|
|
86
|
+
ci = (
|
|
87
|
+
float(np.quantile(deltas, alpha / 2.0)),
|
|
88
|
+
float(np.quantile(deltas, 1.0 - alpha / 2.0)),
|
|
89
|
+
)
|
|
90
|
+
logger.debug("Bootstrap CI computed: [%.6f, %.6f]", ci[0], ci[1])
|
|
91
|
+
return ci
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def select_decision_metric(
|
|
95
|
+
verdicts: list[RegressionVerdict],
|
|
96
|
+
) -> RegressionVerdict:
|
|
97
|
+
if not verdicts:
|
|
98
|
+
raise ValueError("At least one verdict is required.")
|
|
99
|
+
|
|
100
|
+
selected = min(
|
|
101
|
+
verdicts,
|
|
102
|
+
key=lambda verdict: (
|
|
103
|
+
-SEVERITY_WEIGHT[verdict.severity],
|
|
104
|
+
(
|
|
105
|
+
verdict.effect_size
|
|
106
|
+
if verdict.effect_size is not None
|
|
107
|
+
else 0.0
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
logger.debug(
|
|
112
|
+
"Selected decision metric '%s' (severity=%s, effect_size=%s)",
|
|
113
|
+
selected.metric_name,
|
|
114
|
+
selected.severity.value,
|
|
115
|
+
selected.effect_size,
|
|
116
|
+
)
|
|
117
|
+
return selected
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def evaluate_gate(
|
|
121
|
+
*,
|
|
122
|
+
verdicts: list[RegressionVerdict],
|
|
123
|
+
scores_by_metric: dict[
|
|
124
|
+
str,
|
|
125
|
+
tuple[np.ndarray, np.ndarray],
|
|
126
|
+
],
|
|
127
|
+
) -> GateVerdict:
|
|
128
|
+
if not verdicts:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"Gate requires at least one regression verdict."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
logger.info("Evaluating gate across %d regression verdict(s)", len(verdicts))
|
|
134
|
+
|
|
135
|
+
decision_metric = select_decision_metric(verdicts)
|
|
136
|
+
|
|
137
|
+
candidate_scores, baseline_scores = scores_by_metric[
|
|
138
|
+
decision_metric.metric_name
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
confidence_interval = bootstrap_delta_ci(
|
|
142
|
+
candidate_scores,
|
|
143
|
+
baseline_scores,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
blocking_verdicts = [
|
|
147
|
+
verdict
|
|
148
|
+
for verdict in verdicts
|
|
149
|
+
if verdict.severity == RegressionSeverity.BLOCKING
|
|
150
|
+
]
|
|
151
|
+
passed = len(blocking_verdicts) == 0
|
|
152
|
+
|
|
153
|
+
if passed:
|
|
154
|
+
logger.info(
|
|
155
|
+
"Gate evaluation result: PASSED for run_id=%s",
|
|
156
|
+
decision_metric.run_id,
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
logger.warning(
|
|
160
|
+
"Gate evaluation result: BLOCKED for run_id=%s (%d blocking regression(s))",
|
|
161
|
+
decision_metric.run_id,
|
|
162
|
+
len(blocking_verdicts),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return GateVerdict(
|
|
166
|
+
passed=passed,
|
|
167
|
+
confidence_interval=confidence_interval,
|
|
168
|
+
regression_verdicts=verdicts,
|
|
169
|
+
run_id=decision_metric.run_id,
|
|
170
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from nirizan.instrumentation.spans import Trace
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseExporter(ABC):
|
|
10
|
+
"""Abstract base class for all trace exporters."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def export(self, trace: Trace) -> None:
|
|
14
|
+
"""Export a completed Trace to storage or a remote collector."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
async def shutdown(self) -> None:
|
|
18
|
+
"""Release underlying connections or background workers."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InMemoryExporter(BaseExporter):
|
|
23
|
+
"""In-memory trace collector designed for unit tests and local experiments."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._traces: list[Trace] = []
|
|
27
|
+
|
|
28
|
+
async def export(self, trace: Trace) -> None:
|
|
29
|
+
self._traces.append(trace)
|
|
30
|
+
|
|
31
|
+
def get_traces(self) -> list[Trace]:
|
|
32
|
+
return list(self._traces)
|
|
33
|
+
|
|
34
|
+
def clear(self) -> None:
|
|
35
|
+
self._traces.clear()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConsoleExporter(BaseExporter):
|
|
39
|
+
"""Logs trace telemetry directly to standard logger output."""
|
|
40
|
+
|
|
41
|
+
async def export(self, trace: Trace) -> None:
|
|
42
|
+
logger.info(
|
|
43
|
+
"Trace Exported | ID: %s | App: %s | Spans: %d",
|
|
44
|
+
trace.trace_id,
|
|
45
|
+
trace.application_name,
|
|
46
|
+
len(trace.spans),
|
|
47
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# src/nirizan/instrumentation/sdk.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
from typing import Any, Callable, Coroutine, Optional, ParamSpec, TypeVar
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from nirizan._logging import get_logger
|
|
9
|
+
from nirizan.instrumentation.exporters import BaseExporter
|
|
10
|
+
from nirizan.instrumentation.spans import SpanKind
|
|
11
|
+
from nirizan.instrumentation.tracer import Tracer
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
_GLOBAL_TRACER: Optional[Tracer] = None
|
|
16
|
+
|
|
17
|
+
P = ParamSpec("P")
|
|
18
|
+
R = TypeVar("R")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def init_tracer(
|
|
22
|
+
application_name: str, exporter: Optional[BaseExporter] = None
|
|
23
|
+
) -> Tracer:
|
|
24
|
+
"""Initialize and register the global tracer instance."""
|
|
25
|
+
global _GLOBAL_TRACER
|
|
26
|
+
tracer = Tracer(application_name=application_name, exporter=exporter)
|
|
27
|
+
_GLOBAL_TRACER = tracer
|
|
28
|
+
logger.info("Initialized global tracer for application '%s'", application_name)
|
|
29
|
+
return tracer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_tracer() -> Optional[Tracer]:
|
|
33
|
+
"""Return the currently configured global tracer instance."""
|
|
34
|
+
return _GLOBAL_TRACER
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def start_session(session_id: Optional[UUID] = None) -> Any:
|
|
38
|
+
"""SDK-level context manager pass-through for grouping traces into a session."""
|
|
39
|
+
tracer = get_tracer()
|
|
40
|
+
if tracer is None:
|
|
41
|
+
raise RuntimeError("Tracer is not initialized. Call init_tracer() first.")
|
|
42
|
+
logger.debug("Starting SDK session (session_id=%s)", session_id)
|
|
43
|
+
return tracer.session(session_id=session_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_input_payload(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str | None:
|
|
47
|
+
if args:
|
|
48
|
+
return str(args[0])
|
|
49
|
+
if kwargs:
|
|
50
|
+
return str(next(iter(kwargs.values())))
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def trace_span(
|
|
55
|
+
kind: SpanKind,
|
|
56
|
+
name: Optional[str] = None,
|
|
57
|
+
tracer: Optional[Tracer] = None,
|
|
58
|
+
) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
|
|
59
|
+
"""Decorator to instrument an async function with full signature preservation."""
|
|
60
|
+
def decorator(
|
|
61
|
+
func: Callable[P, Coroutine[Any, Any, R]]
|
|
62
|
+
) -> Callable[P, Coroutine[Any, Any, R]]:
|
|
63
|
+
span_name = name or func.__name__
|
|
64
|
+
|
|
65
|
+
@functools.wraps(func)
|
|
66
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
67
|
+
active_tracer = tracer or get_tracer()
|
|
68
|
+
if active_tracer is None:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"Tracer is not initialized. Call init_tracer() before executing traced code."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
input_payload = _format_input_payload(args, kwargs)
|
|
74
|
+
|
|
75
|
+
async with active_tracer.start_span(
|
|
76
|
+
name=span_name, kind=kind, input_payload=input_payload
|
|
77
|
+
) as handle:
|
|
78
|
+
result = await func(*args, **kwargs)
|
|
79
|
+
if result is not None and handle.output_payload is None:
|
|
80
|
+
handle.output_payload = str(result)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
return wrapper
|
|
84
|
+
|
|
85
|
+
return decorator
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def planning(
|
|
89
|
+
name: Optional[str] = None,
|
|
90
|
+
tracer: Optional[Tracer] = None,
|
|
91
|
+
) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
|
|
92
|
+
"""Convenience decorator for PLANNING span instrumentation."""
|
|
93
|
+
return trace_span(kind=SpanKind.PLANNING, name=name, tracer=tracer)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def retrieval(
|
|
97
|
+
name: Optional[str] = None,
|
|
98
|
+
tracer: Optional[Tracer] = None,
|
|
99
|
+
) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
|
|
100
|
+
"""Convenience decorator for RETRIEVAL span instrumentation."""
|
|
101
|
+
return trace_span(kind=SpanKind.RETRIEVAL, name=name, tracer=tracer)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generation(
|
|
105
|
+
name: Optional[str] = None,
|
|
106
|
+
tracer: Optional[Tracer] = None,
|
|
107
|
+
) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
|
|
108
|
+
"""Convenience decorator for GENERATION span instrumentation."""
|
|
109
|
+
return trace_span(kind=SpanKind.GENERATION, name=name, tracer=tracer)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def tool_use(
|
|
113
|
+
name: Optional[str] = None,
|
|
114
|
+
tracer: Optional[Tracer] = None,
|
|
115
|
+
) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
|
|
116
|
+
"""Convenience decorator for TOOL_USE span instrumentation."""
|
|
117
|
+
return trace_span(kind=SpanKind.TOOL_USE, name=name, tracer=tracer)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Session(BaseModel):
|
|
10
|
+
"""Groups multiple Trace ids belonging to one multi-turn conversation; not frozen, open until ended."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(strict=True)
|
|
13
|
+
|
|
14
|
+
session_id: UUID
|
|
15
|
+
application_name: str = Field(min_length=1)
|
|
16
|
+
trace_ids: list[UUID] = Field(default_factory=list)
|
|
17
|
+
started_at: datetime
|
|
18
|
+
ended_at: datetime | None = None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpanKind(str, Enum):
|
|
11
|
+
"""The functional role of an execution span."""
|
|
12
|
+
|
|
13
|
+
PLANNING = "planning"
|
|
14
|
+
RETRIEVAL = "retrieval"
|
|
15
|
+
TOOL_USE = "tool_use"
|
|
16
|
+
GENERATION = "generation"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Span(BaseModel):
|
|
20
|
+
"""The atomic unit of instrumentation: one step in an AI execution graph."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(frozen=True, strict=True)
|
|
23
|
+
|
|
24
|
+
span_id: UUID
|
|
25
|
+
trace_id: UUID
|
|
26
|
+
parent_span_id: UUID | None = None
|
|
27
|
+
kind: SpanKind
|
|
28
|
+
name: str = Field(min_length=1, max_length=200)
|
|
29
|
+
started_at: datetime
|
|
30
|
+
ended_at: datetime
|
|
31
|
+
attributes: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
32
|
+
input_payload: str | None = None
|
|
33
|
+
output_payload: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Trace(BaseModel):
|
|
37
|
+
"""An ordered collection of spans belonging to a single invocation."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(strict=True)
|
|
40
|
+
|
|
41
|
+
trace_id: UUID
|
|
42
|
+
application_name: str = Field(min_length=1)
|
|
43
|
+
spans: list[Span] = Field(default_factory=list)
|
|
44
|
+
created_at: datetime
|
|
45
|
+
code_commit: str | None = None # Phase 3: stamped by collector.py at ingest
|
|
46
|
+
data_snapshot_id: str | None = None # Phase 3: stamped by collector.py at ingest
|
|
47
|
+
session_id: UUID | None = None # Phase 3: set when captured inside Tracer.session(...)
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="after")
|
|
50
|
+
def validate_span_trace_ids(self) -> Trace:
|
|
51
|
+
"""Ensure all spans in the trace share the trace's trace_id."""
|
|
52
|
+
for span in self.spans:
|
|
53
|
+
if span.trace_id != self.trace_id:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Span {span.span_id} trace_id ({span.trace_id}) "
|
|
56
|
+
f"does not match Trace trace_id ({self.trace_id})"
|
|
57
|
+
)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def spans_of_kind(self, kind: SpanKind) -> list[Span]:
|
|
61
|
+
"""Return all spans matching a specific SpanKind."""
|
|
62
|
+
return [s for s in self.spans if s.kind == kind]
|