telemetry: zero-initialize bounded-label metrics so an absent series means a broken scrape (#927)
* telemetry: materialize dropped-event counter children at 0
A labeled Prometheus counter exports no series until its first labels()
call, so telemetry_events_dropped stayed invisible until an event was
actually dropped — impossible to alert on or graph, and "no drops" was
indistinguishable from "metric missing / scrape broken".
Pre-create the (namespace, reason) children at 0 on emitter start, for
each reason the emitter can emit, so the metric is always present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* telemetry: generalize counter zero-init to all bounded-label counters
Extends #927 (which zero-inited telemetry_events_dropped) to every counter
whose label domain is bounded and known at startup, so metrics are present in
Prometheus before their first event — a missing series then signals a broken
scrape rather than "nothing happened yet".
- add initialize_bounded_metrics(instance_type) on PrometheusMetrics; call it
per-process from main.py (api) and deriver/__main__.py (deriver).
- extract a shared _touch() helper; refactor initialize_telemetry_dropped_metrics
onto it (that one stays per-emitter in start() — it's prefix-dependent).
- explicit ALL_EVENT_TYPES / HIGH_VOLUME_EVENT_TYPES registry in telemetry.events,
drift-guarded by tests that walk BaseEvent subclasses.
- only VALID (task_type, token_type, component) tuples for deriver_tokens (the
cartesian product would fabricate impossible always-0 series); only high-volume
event types for sampled_out; high-cardinality labels (endpoint, workspace_name)
left open.
- gauges: zero-init embed_now_tasks_in_flight + telemetry_buffer_size; add a new
message_embeddings_pending backlog gauge, set each reconciliation cycle and
zero-inited at deriver startup (Rajat's pending/in-flight ask).
- backfills the tests #927 shipped without.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* review: task-aware deriver combos + fail-soft gauge zero-init
I1: _DERIVER_TOKEN_COMBOS was factored task-independently, materializing the
impossible (ingestion, input, previous_summary) series — previous_summary is
summary-only. Make combos task-aware (_DERIVER_TOKEN_COMBOS_BY_TASK) so no
always-0 impossible series is fabricated, matching the PR's own goal. Tests
tightened to assert the ingestion/previous_summary series is absent.
I2: the three gauge .set(0) zero-inits were bare while the counter inits go
through the fail-soft _touch. Add _set_gauge_zero() so a gauge init can't
propagate an exception into process startup either.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(telemetry): isolate zero-init namespaces, add deriver-to-api guard
Global-REGISTRY assertions used a fixed "test" namespace, which several
other suites also pin, so another test's materialized children could
satisfy a presence assertion or break an absence one. Each test now runs
under a unique namespace resolved from settings at read time.
Adds the inverse per-process isolation test: deriver-only init must not
materialize API-only series (dialectic tokens, embed_now).
Co-Authored-By: Claude <noreply@anthropic.com>
* review: per-replica backlog gauge, drop duplicated constants and .meta refs
Addresses Vineeth's review on #927.
Blocking:
- message_embeddings_pending is a DB-global count, so drive it from
ReconcilerScheduler._scheduler_loop (runs on every replica, every
interval) instead of run_vector_reconciliation_cycle (runs off the
queue behind work-unit dedup, so one replica per cycle). Combined with
the zero-init, the old placement made every replica that never won the
work unit export a confident permanent 0. Help string now names the
owner so dashboards don't reach for sum().
- guard initialize_telemetry_dropped_metrics on METRICS.ENABLED,
matching its sibling initializer.
- drop the duplicate REASONING_LEVELS; import the one in src/config.
Non-blocking:
- walk BaseSpecialist recursively via a shared utils.types.walk_subclasses
(replaces the direct-children-only __subclasses__() and the test's
private copy of the same helper).
- derive the specialist assertion from the subclasses instead of
hardcoding two names — the hardcoded pair kept passing after
CardRefreshSpecialist landed, leaving it uncovered.
- inline the zero-init rationale and the multi-instance bucket taxonomy;
removes both pointers to a .meta design doc that is not in the repo.
Tests: new tests/reconciler/test_pending_backlog_gauge.py pins both
halves of the relocation (verified it fails when reverted).
* review: fix inert test guard, stale comments, and the REASONING_LEVELS drift claim
Second review pass on the branch. Findings, most severe first:
- tests/reconciler/test_pending_backlog_gauge.py: the _try_enqueue_task stub
was patched onto the class but declared without `self`, so calling it
raised TypeError — which _scheduler_loop swallows. The guard was inert and
the test passed for the wrong reason. Fixed the arity.
- metrics.py still commented that the backlog gauge is "set live each
reconciliation cycle". That is the exact claim the previous commit
overturned; it now contradicted the help string, the bucket-3 docstring
and sync_vectors.py.
- metrics.py claimed REASONING_LEVELS is "derived from the config Literal so
it never drifts", but config.py hand-listed it, so the earlier dedup had
quietly traded away the guarantee the original get_args() call provided.
Made it true instead: config.REASONING_LEVELS = list(get_args(...)), which
keeps the dedup and restores the invariant.
- dropped _set_gauge_zero: all three gauges it zeroed already have identical
fail-soft setters, so it was a second way to do one thing. Using the
setters also makes _handle_metric_error name the actual gauge.
- record_pending_embeddings_backlog's docstring oversold the covering index
as making the COUNT "negligible". The index makes cost proportional to the
pending backlog, not to the table — which is worst precisely when the
backlog matters. Stated honestly.
- _scheduler_loop's docstring said it only enqueues; it also refreshes the
gauge, at a cadence set by the shortest task interval.
- comment reconciliation: stripped #927 / "the generalization" temporal
anchoring, a CardRefreshSpecialist change-narration clause, and
reviewer-directed phrasing from the test file; disambiguated the
src/utils/summarizer.py path.
- CLAUDE.md had no Prometheus section at all, so the new "add a BaseEvent
subclass -> update ALL_EVENT_TYPES" obligation and the never-sum() rule
for non-additive gauges were undiscoverable from the architecture doc.
Verified: ruff + basedpyright clean (0 errors), tests/telemetry + reconciler
+ dialectic + llm 497 passed, full suite 1768 passed with only the 4
pre-existing test_document failures (OpenAI key required, reproduced on
clean origin/main). Re-confirmed the relocation guard fails when reverted.
* fix: silence the two basedpyright warnings inherited from main
CI runs `uv run basedpyright` bare, and basedpyright exits non-zero on any
warning — so these two have been failing the staticanalysis job on every
branch cut from current main, not just this one:
- src/vector_store/__init__.py:209 implicit string concatenation (#496)
- tests/test_cache_redaction.py:5 private import (#869)
Both predate this branch and are unrelated to the telemetry work; fixed
here only because they block this PR from going green. Verified: clean
origin/main also reports "0 errors, 2 warnings" and exits 1.
basedpyright now 0 errors, 0 warnings, exit 0.
* docs(telemetry): make the bucket-3 aggregation rule precise
The multi-instance taxonomy said a service-scoped non-additive metric has
"no aggregation correct once they disagree", then immediately mandated that
every instance refresh on its own timer. Those undercut each other: staggered
timers ALWAYS disagree slightly, so as written the rule reads as "ensure they
don't", which is unachievable, and it leaves the reader unsure whether max()
and avg() survived the fix.
The actual rule is bounded disagreement plus a scale-preserving aggregator.
Instances are N witnesses to one fact, not N parts of one whole, so sum() can
never be correct (it scales with replica count) while max()/avg()/quantiles
are correct precisely because the per-instance timer bounds the spread.
Wording only; no behavior change. The gauge help string already said
"max() or avg(), never sum()" — this makes the normative docstring agree
with it. Surfaced walking Vineeth's comment 3668208059 for comprehension.
* refactor(bench): import REASONING_LEVELS from config instead of re-listing
Third copy of the constant, missed when ee781c0/694e07f deduped the other
two. This one re-declared the ReasoningLevel Literal as well as the list,
so the type alias could diverge from config's with nothing to catch it —
and the list was hand-written, the variant that typechecks clean while
missing a member.
No import barrier justified it: this module already imports from src, as do
seven of its siblings in tests/bench. Concrete effect of the drift was that
a newly added sixth reasoning level would be rejected by the bench CLI's
argparse choices=.
src.config.REASONING_LEVELS is now the single definition repo-wide.
* test(telemetry): pin the METRICS.ENABLED guard on the per-emitter initializer
initialize_telemetry_dropped_metrics gained a METRICS.ENABLED guard in
ee781c0, addressing Vineeth's asymmetry comment, but nothing asserted it —
it had only the enabled half of the pair its sibling has. Deleting the guard
left the suite green, so the fix closed the asymmetry in the guards and
reproduced it one level up in the tests.
Mirrors test_init_noop_when_metrics_disabled. Verified live rather than
assumed: deleting the two guard lines turns this test red.
Uses a unique namespace, without which the absence assertion would be
satisfied by the enabled test's children rather than by the guard.
* docs(telemetry): fold zero-init why-prose behind # region ai markers
Comment/docstring-only pass over the changed files, per the groudon
comment-marker standard: the terse human-facing "what" stays visible, and
load-bearing "why" (the zero-init / absent-series-means-broken-scrape
rationale, gotchas, receipts) folds into # region ai / # ai: blocks.
Behavior-preserving: AST-identical modulo docstrings/comments vs the
pre-pass merge; ruff, ruff format --check, and basedpyright all clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7bafee5de1
commit
4797489281
|
|
@ -187,6 +187,9 @@ The Dreamer is an orchestrated multi-specialist system that runs during schedule
|
|||
- **LLM subsystem** (`src/llm/`): provider-agnostic `honcho_llm_call()`. Backends in `src/llm/backends/` (`anthropic.py`, `gemini.py`, `openai.py`). Includes prompt caching (`caching.py`), structured output (`structured_output.py`), tool loop (`tool_loop.py`), history adapters for cross-provider message formats, and a model registry. Per-retry provider selection is pinned via an `AttemptPlan` so stream-final retries don't bounce back to primary after the tool loop has settled on fallback.
|
||||
- **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`).
|
||||
- **Telemetry**: cloudevents in `src/telemetry/events/` cover API routes, dialectic, dream, deletion, reconciliation, representation, and per-call LLM accounting (`llm.py` — `LLMCallCompletedEvent` fires once per provider hit with full cost-attribution context). High-volume events are sampled deterministically per `run_id` via `TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE`.
|
||||
- **Prometheus metrics** (`src/telemetry/prometheus/`): every metric carries a `namespace` label and every recorder is fail-soft (a metrics error never propagates into a request or a worker loop). Counter children with a *bounded* label domain are zero-initialized per process at startup — `initialize_bounded_metrics(instance_type=...)`, called from the `src/main.py` lifespan (`api`) and `src/deriver/__main__.py` (`deriver`) — so an absent series means a broken scrape rather than "nothing happened". Two consequences worth knowing before touching telemetry:
|
||||
- **Adding a `BaseEvent` subclass requires adding its `_event_type` to `ALL_EVENT_TYPES`** in `src/telemetry/events/__init__.py` (and to `HIGH_VOLUME_EVENT_TYPES` if `_volume_class == "high_volume"`). Enforced by the drift guards in `tests/telemetry/test_metric_zero_init.py`, which assert set-equality against the discovered subclasses.
|
||||
- **A service-wide, non-additive gauge must be refreshed by every replica on its own timer**, and aggregated with `max()`/`avg()`, never `sum()`. `message_embeddings_pending` is the example: it reports a DB-global count, so it is driven from `ReconcilerScheduler._scheduler_loop` (runs on all replicas) rather than from the work-unit-deduped reconciliation cycle — otherwise, combined with the zero-init, every replica that never won the work unit would export a confident permanent `0`.
|
||||
|
||||
### Project Structure
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import logging
|
|||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, ClassVar, Literal, cast
|
||||
from typing import Annotated, Any, ClassVar, Literal, cast, get_args
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import tomllib
|
||||
|
|
@ -998,15 +998,14 @@ class PeerCardSettings(HonchoSettings):
|
|||
ENABLED: bool = True
|
||||
|
||||
|
||||
# Reasoning levels for dialectic - defined here to avoid circular imports with schemas
|
||||
# Reasoning levels for dialectic - defined here to avoid circular imports with schemas.
|
||||
# region ai
|
||||
# REASONING_LEVELS is derived from the Literal, not hand-listed: the annotation
|
||||
# rejects an invalid member but not a MISSING one, so a hand-written copy could
|
||||
# silently drop a level and still typecheck.
|
||||
# endregion
|
||||
ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"]
|
||||
REASONING_LEVELS: list[ReasoningLevel] = [
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
]
|
||||
REASONING_LEVELS: list[ReasoningLevel] = list(get_args(ReasoningLevel))
|
||||
|
||||
|
||||
class DialecticLevelSettings(BaseModel):
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from src.db import engine, register_db_query_instrumentation
|
|||
from src.startup import validate_embedding_schema
|
||||
from src.telemetry import (
|
||||
initialize_telemetry_async,
|
||||
prometheus_metrics,
|
||||
register_db_pool_collector,
|
||||
shutdown_telemetry,
|
||||
)
|
||||
|
|
@ -25,6 +26,12 @@ def start_metrics_server() -> None:
|
|||
# Expose DB connection-pool stats for this deriver instance.
|
||||
register_db_pool_collector("deriver")
|
||||
register_db_query_instrumentation("deriver")
|
||||
|
||||
# region ai
|
||||
# Zero-init bounded-label counters so a missing series signals a broken scrape,
|
||||
# not "no events" — see initialize_bounded_metrics. No-op if metrics off.
|
||||
# endregion
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
|
||||
logger.info("Prometheus metrics server started on port 9090")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@ async def lifespan(_: FastAPI):
|
|||
register_db_pool_collector("api")
|
||||
register_db_query_instrumentation("api")
|
||||
|
||||
# region ai
|
||||
# Zero-init bounded-label counters so a missing series signals a broken scrape,
|
||||
# not "no events" — see initialize_bounded_metrics. No-op if metrics off.
|
||||
# endregion
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
|
||||
# Validate embedding schema before serving any traffic. Fails closed: if
|
||||
# the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical
|
||||
# pgvector columns, the process refuses to start rather than silently
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from src import models
|
|||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.models import QueueItem
|
||||
from src.reconciler.sync_vectors import record_pending_embeddings_backlog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -145,15 +146,26 @@ class ReconcilerScheduler:
|
|||
|
||||
async def _scheduler_loop(self) -> None:
|
||||
"""
|
||||
Main scheduler loop that enqueues tasks based on their intervals.
|
||||
Main scheduler loop that enqueues tasks based on their intervals, and
|
||||
refreshes the service-wide pending-embeddings backlog gauge each pass.
|
||||
|
||||
Each task has its own interval and the loop checks all tasks on each
|
||||
iteration, enqueueing any that are due.
|
||||
iteration, enqueueing any that are due. The loop sleeps until the next
|
||||
task is due, so the gauge's refresh cadence tracks the SHORTEST task
|
||||
interval.
|
||||
"""
|
||||
try:
|
||||
while not self._shutdown_event.is_set():
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# region ai
|
||||
# Refresh on EVERY replica, not just whichever wins the sync_vectors
|
||||
# work unit: the count is DB-global, so a replica that never ran a
|
||||
# cycle would otherwise export a stale (or zero-initialized) value
|
||||
# forever. Full rationale in record_pending_embeddings_backlog.
|
||||
# endregion
|
||||
await record_pending_embeddings_backlog()
|
||||
|
||||
# Check each task and enqueue if due
|
||||
for task_name, task in RECONCILER_TASKS.items():
|
||||
next_run = self._next_run.get(task_name, now)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from src.config import settings
|
|||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
from src.exceptions import VectorStoreError
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import EmbeddingCallPurpose
|
||||
from src.utils.types import embedding_call_purpose
|
||||
from src.vector_store import VectorRecord, VectorStore, get_external_vector_store
|
||||
|
|
@ -700,6 +701,42 @@ async def _cleanup_pgvector_batch(
|
|||
return True
|
||||
|
||||
|
||||
async def record_pending_embeddings_backlog() -> None:
|
||||
"""Set the pending-embeddings backlog gauge to the current count of
|
||||
MessageEmbedding rows awaiting a vector (sync_state='pending')."""
|
||||
# region ai
|
||||
# Called from ``ReconcilerScheduler._scheduler_loop``, deliberately NOT from
|
||||
# ``run_vector_reconciliation_cycle``: the cycle runs off the queue behind
|
||||
# work-unit dedup, so exactly one deriver replica executes it. Driving the gauge
|
||||
# from there would leave every other replica exporting a stale value — or, since
|
||||
# this metric is zero-initialized, a confident permanent 0 it never measured. The
|
||||
# count is a property of the database, not the process, so every replica must
|
||||
# refresh it on its own timer for ``max()``/``avg()`` to mean anything.
|
||||
#
|
||||
# Cost: one COUNT per replica per scheduler interval (~5 min by default).
|
||||
# ``ix_message_embeddings_sync_state_last_sync_at`` keeps the scan proportional to
|
||||
# the pending backlog, not the whole table — which is not the same as cheap: after
|
||||
# an embedding outage the backlog is exactly what is large. Still a small duty
|
||||
# cycle, and the cost shrinks as the reconciler drains.
|
||||
#
|
||||
# Best-effort: a metrics/DB hiccup here must never break the scheduler loop.
|
||||
# endregion
|
||||
if not settings.METRICS.ENABLED:
|
||||
return
|
||||
try:
|
||||
async with tracked_db("reconciler_pending_count", read_only=True) as db:
|
||||
count = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(models.MessageEmbedding)
|
||||
.where(models.MessageEmbedding.sync_state == "pending")
|
||||
)
|
||||
prometheus_metrics.set_message_embeddings_pending(count=count or 0)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to record pending-embeddings backlog gauge", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def run_vector_reconciliation_cycle() -> ReconciliationMetrics:
|
||||
"""
|
||||
Run a complete reconciliation cycle.
|
||||
|
|
|
|||
|
|
@ -172,6 +172,21 @@ class TelemetryEmitter:
|
|||
)
|
||||
self._running = True
|
||||
self._flush_task = asyncio.create_task(self._periodic_flush())
|
||||
|
||||
# region ai
|
||||
# Pre-create the dropped-event counter children at 0: a labeled counter
|
||||
# exports nothing until its first observation, so this makes the metric
|
||||
# visible before any drop and lets us tell "no drops" from "metric missing".
|
||||
# endregion
|
||||
from src.telemetry.prometheus.metrics import prometheus_metrics
|
||||
|
||||
prometheus_metrics.initialize_telemetry_dropped_metrics(
|
||||
reasons=[
|
||||
f"{self.drop_reason_prefix}buffer_full",
|
||||
f"{self.drop_reason_prefix}send_failed",
|
||||
]
|
||||
)
|
||||
|
||||
logger.info("Telemetry emitter started, endpoint: %s", self.endpoint)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
|
|
|
|||
|
|
@ -138,9 +138,74 @@ __all__ = [
|
|||
# Lifecycle
|
||||
"initialize_telemetry_events",
|
||||
"shutdown_telemetry_events",
|
||||
# Zero-init registry
|
||||
"ALL_EVENT_TYPES",
|
||||
"HIGH_VOLUME_EVENT_TYPES",
|
||||
]
|
||||
|
||||
|
||||
# Explicit registry of CloudEvents `type` values, used to zero-initialize the
|
||||
# telemetry_events_emitted / telemetry_events_sampled_out counter children.
|
||||
# region ai
|
||||
# See metrics.py:initialize_bounded_metrics for why absent and zero are worth
|
||||
# distinguishing. Explicit literal, not a set derived from BaseEvent subclasses: a
|
||||
# derived set would follow whatever happens to be imported at init time, so a type
|
||||
# could drop out of the registry with no code change. A hand-maintained list plus a
|
||||
# drift-guard test fails loud at the right moment instead.
|
||||
#
|
||||
# When you add a BaseEvent subclass, add its `_event_type` here (and to
|
||||
# HIGH_VOLUME_EVENT_TYPES if `_volume_class == "high_volume"`). The drift-guard test
|
||||
# tests/telemetry/test_metric_zero_init.py fails until you do — it asserts this
|
||||
# registry equals the set discovered by walking BaseEvent subclasses.
|
||||
# endregion
|
||||
ALL_EVENT_TYPES: tuple[str, ...] = (
|
||||
# api
|
||||
"message.created",
|
||||
"file.uploaded",
|
||||
"context.retrieved",
|
||||
# agent
|
||||
"agent.iteration",
|
||||
"agent.tool.conclusions.created",
|
||||
"agent.tool.conclusions.deleted",
|
||||
"agent.tool.peer_card.updated",
|
||||
"agent.tool.summary.created",
|
||||
"agent.tool.call.completed",
|
||||
# deletion / dialectic / dream / representation
|
||||
"deletion.completed",
|
||||
"dialectic.completed",
|
||||
"dream.run",
|
||||
"dream.specialist",
|
||||
"representation.completed",
|
||||
# llm / embedding
|
||||
"llm.call.completed",
|
||||
"embedding.call.completed",
|
||||
# reconciliation
|
||||
"reconciliation.sync_vectors.completed",
|
||||
"reconciliation.cleanup_stale_items.completed",
|
||||
# trace stream
|
||||
# region ai
|
||||
# Only emitted when TELEMETRY.TRACE_PAYLOADS_ENABLED, but they flow through the
|
||||
# same emit() path and increment the same counters, so they belong in the set.
|
||||
# endregion
|
||||
"llm.call.traced",
|
||||
"embedding.call.traced",
|
||||
"trace.content",
|
||||
)
|
||||
|
||||
# Subset of ALL_EVENT_TYPES whose `_volume_class == "high_volume"`.
|
||||
# region ai
|
||||
# Only these can ever be counted by ``telemetry_events_sampled_out``; ground_truth
|
||||
# events skip the sampler entirely (pre-creating their sampled_out series would be a
|
||||
# permanently-misleading 0).
|
||||
# endregion
|
||||
HIGH_VOLUME_EVENT_TYPES: tuple[str, ...] = (
|
||||
"agent.iteration",
|
||||
"agent.tool.call.completed",
|
||||
"llm.call.completed",
|
||||
"embedding.call.completed",
|
||||
)
|
||||
|
||||
|
||||
def emit(event: BaseEvent) -> None:
|
||||
"""Queue an event for emission to the telemetry backend.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ from prometheus_client.core import GaugeMetricFamily
|
|||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from src.config import settings
|
||||
from src.config import REASONING_LEVELS, settings
|
||||
from src.utils.types import walk_subclasses
|
||||
|
||||
disable_created_metrics()
|
||||
|
||||
|
|
@ -66,6 +67,32 @@ class DialecticComponents(Enum):
|
|||
TOTAL = "total"
|
||||
|
||||
|
||||
# Valid (token_type, component) pairs for deriver_tokens_processed, per task_type,
|
||||
# used to zero-initialize counter children (see initialize_bounded_metrics).
|
||||
# region ai
|
||||
# NOT the cartesian product: input tokens only pair with input components, output
|
||||
# only with OUTPUT_TOTAL, and PREVIOUS_SUMMARY occurs only for summary tasks
|
||||
# (ingestion has no previous summary). Enumerating anything broader would fabricate
|
||||
# impossible always-0 series (e.g. output/prompt, or ingestion/previous_summary).
|
||||
# Explicit literal, drift-guarded by tests/telemetry/test_metric_zero_init.py.
|
||||
# Sources: track_deriver_input_tokens (src/utils/tokens.py) + the OUTPUT_TOTAL sites
|
||||
# in src/deriver/deriver.py and src/utils/summarizer.py.
|
||||
# endregion
|
||||
_DERIVER_TOKEN_COMBOS_BY_TASK: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
DeriverTaskTypes.INGESTION.value: (
|
||||
(TokenTypes.INPUT.value, DeriverComponents.PROMPT.value),
|
||||
(TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value),
|
||||
(TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value),
|
||||
),
|
||||
DeriverTaskTypes.SUMMARY.value: (
|
||||
(TokenTypes.INPUT.value, DeriverComponents.PROMPT.value),
|
||||
(TokenTypes.INPUT.value, DeriverComponents.MESSAGES.value),
|
||||
(TokenTypes.INPUT.value, DeriverComponents.PREVIOUS_SUMMARY.value),
|
||||
(TokenTypes.OUTPUT.value, DeriverComponents.OUTPUT_TOTAL.value),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
api_requests_counter = NamespacedCounter(
|
||||
"api_requests",
|
||||
"Total API requests",
|
||||
|
|
@ -155,6 +182,23 @@ telemetry_buffer_size_gauge = NamespacedGauge(
|
|||
["namespace"],
|
||||
)
|
||||
|
||||
# Embedding backlog: MessageEmbedding rows still awaiting a vector
|
||||
# (sync_state='pending').
|
||||
# region ai
|
||||
# Distinct from embed_now_tasks_in_flight (in-flight fast-path work in the API
|
||||
# process) — this is the durable, DB-wide backlog the reconciler drains. Every
|
||||
# deriver replica refreshes it on its own timer from
|
||||
# ReconcilerScheduler._scheduler_loop, so replicas disagree by at most one interval.
|
||||
# Service-wide, not per-process — hence the help string's "never sum()".
|
||||
# endregion
|
||||
message_embeddings_pending_gauge = NamespacedGauge(
|
||||
"message_embeddings_pending",
|
||||
"MessageEmbedding rows awaiting embedding (sync_state='pending'). "
|
||||
+ "Service-wide DB count, reported independently by every replica — "
|
||||
+ "aggregate with max() or avg(), never sum()",
|
||||
["namespace"],
|
||||
)
|
||||
|
||||
# DB connection-pool health. The in-flight gauge counts statements actually
|
||||
# executing on the wire, so checked_out minus in_flight reveals connections held
|
||||
# but parked (the "idle in transaction during an external call" antipattern).
|
||||
|
|
@ -325,12 +369,159 @@ class PrometheusMetrics:
|
|||
except Exception as e:
|
||||
self._handle_metric_error("record_telemetry_event_dropped", e)
|
||||
|
||||
def _touch(self, counter: NamespacedCounter, **labels: str) -> None:
|
||||
"""Pre-create a counter child series at 0 without incrementing it."""
|
||||
# region ai
|
||||
# A labeled Prometheus counter exports no time series until its first
|
||||
# ``labels(...)`` call, so pre-touching a child keeps it present at 0 — a
|
||||
# missing series then signals a broken scrape rather than "no events".
|
||||
# Fail-soft (like the recorders): a bad init must never crash startup.
|
||||
# endregion
|
||||
try:
|
||||
counter.labels(**labels)
|
||||
except Exception as e:
|
||||
self._handle_metric_error("_touch", e)
|
||||
|
||||
def initialize_telemetry_dropped_metrics(self, *, reasons: list[str]) -> None:
|
||||
"""Pre-create telemetry_events_dropped ``(namespace, reason)`` children at 0.
|
||||
|
||||
Args:
|
||||
reasons: The reason label values the calling emitter can produce.
|
||||
"""
|
||||
# region ai
|
||||
# The metric stays invisible in Prometheus/Grafana until an event is actually
|
||||
# dropped, so materializing the children at startup keeps it present at 0 — a
|
||||
# missing series then means a broken scrape, not "no drops" (see _touch).
|
||||
#
|
||||
# Called per-emitter from ``TelemetryEmitter.start()`` rather than hoisted into
|
||||
# the process-level ``initialize_bounded_metrics``: the trace emitter (whose
|
||||
# reasons carry a ``trace_`` prefix) only exists when ``TRACE_PAYLOADS_ENABLED``
|
||||
# is set, so hoisting would fabricate ``trace_*`` series on deployments that run
|
||||
# with tracing off.
|
||||
# endregion
|
||||
if not settings.METRICS.ENABLED:
|
||||
return
|
||||
|
||||
for reason in reasons:
|
||||
self._touch(telemetry_events_dropped_counter, reason=reason)
|
||||
|
||||
def initialize_bounded_metrics(self, *, instance_type: str) -> None:
|
||||
"""Pre-create bounded-label counter children at 0 for this process, so an
|
||||
absent series means a broken scrape rather than "nothing happened".
|
||||
|
||||
Args:
|
||||
instance_type: "api" or "deriver" — selects the process-specific
|
||||
counters. Event-type and buffer metrics are initialized in both.
|
||||
"""
|
||||
# region ai
|
||||
# A Prometheus counter does not exist until its first increment, so a
|
||||
# never-yet-incremented metric is indistinguishable from a broken scrape:
|
||||
# you cannot graph or alert on a series that is absent. Materializing the
|
||||
# children at 0 inverts that — a missing series now means something is wrong,
|
||||
# and "no events" reads as a flat 0 instead of a gap.
|
||||
#
|
||||
# That only holds for label sets we can enumerate honestly, so a metric is
|
||||
# initialized here only when its full label domain is bounded, enumerable at
|
||||
# startup, and actually emitted by THIS process. High-cardinality labels
|
||||
# (endpoint, workspace_name) and impossible label tuples are deliberately left
|
||||
# absent — fabricating a permanently-0 series that no code path can ever
|
||||
# increment is the same lie in the other direction.
|
||||
#
|
||||
# Multi-instance safety splits the metrics here into three buckets:
|
||||
#
|
||||
# 1. instance-scoped (``telemetry_buffer_size``, ``embed_now_tasks_in_flight``)
|
||||
# — per-process by nature, so any aggregation is meaningful and zero-init is
|
||||
# unambiguously right.
|
||||
# 2. service-scoped additive (the token counters, ``telemetry_events_emitted``)
|
||||
# — each instance holds a partial count and ``sum()`` reconstructs the whole,
|
||||
# so multi-instance safe.
|
||||
# 3. service-scoped non-additive — every instance reports the whole service's
|
||||
# value, so the instances are N witnesses to one fact rather than N parts of
|
||||
# one whole. ``sum()`` is therefore never correct here: it scales with the
|
||||
# replica count. Scale-preserving aggregations (``max()``, ``avg()``,
|
||||
# quantiles) ARE correct, but only while the witnesses disagree by a bounded
|
||||
# amount — which requires every instance to refresh on its own timer (see
|
||||
# ``message_embeddings_pending``, refreshed per replica from
|
||||
# ``ReconcilerScheduler._scheduler_loop``). A bucket-3 metric that cannot
|
||||
# meet that bar does not belong in the app at all — it belongs in an exporter
|
||||
# that yields exactly one series.
|
||||
#
|
||||
# Prometheus stamps ``instance``/``job`` at scrape time, which is why buckets 1
|
||||
# and 2 need no special handling. ``telemetry_events_dropped`` is handled
|
||||
# separately, per-emitter, in ``TelemetryEmitter.start()`` (prefix-dependent).
|
||||
# endregion
|
||||
if not settings.METRICS.ENABLED:
|
||||
return
|
||||
|
||||
# ai: lazy import avoids an import-time cycle (metrics is imported widely)
|
||||
from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES
|
||||
|
||||
# region ai
|
||||
# Common: both processes run a TelemetryEmitter, so both emit their own subset
|
||||
# of event types. The domain is bounded/low-cardinality (~21 types), so init
|
||||
# the full set in each process rather than maintain a fragile
|
||||
# per-event-type -> process map.
|
||||
# endregion
|
||||
for event_type in ALL_EVENT_TYPES:
|
||||
self._touch(telemetry_events_emitted_counter, type=event_type)
|
||||
for event_type in HIGH_VOLUME_EVENT_TYPES:
|
||||
self._touch(telemetry_events_sampled_out_counter, type=event_type)
|
||||
self.set_telemetry_buffer_size(size=0)
|
||||
|
||||
if instance_type == "api":
|
||||
# dialectic tokens: token_type x component(total) x reasoning_level
|
||||
for token_type in TokenTypes:
|
||||
for level in REASONING_LEVELS:
|
||||
self._touch(
|
||||
dialectic_tokens_processed_counter,
|
||||
token_type=token_type.value,
|
||||
component=DialecticComponents.TOTAL.value,
|
||||
reasoning_level=level,
|
||||
)
|
||||
# ai: embed_now fast path runs as an API-process background task
|
||||
self._touch(embed_now_tasks_shed_counter)
|
||||
self.set_embed_now_tasks_in_flight(0)
|
||||
|
||||
elif instance_type == "deriver":
|
||||
# deriver tokens: only the valid (token_type, component) tuples per
|
||||
# task_type (see _DERIVER_TOKEN_COMBOS_BY_TASK).
|
||||
for task_type_value, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items():
|
||||
for token_type_value, component_value in combos:
|
||||
self._touch(
|
||||
deriver_tokens_processed_counter,
|
||||
task_type=task_type_value,
|
||||
token_type=token_type_value,
|
||||
component=component_value,
|
||||
)
|
||||
# dreamer tokens: specialist_name x token_type.
|
||||
# region ai
|
||||
# Names come from the concrete BaseSpecialist subclasses (walked recursively
|
||||
# via walk_subclasses) so a new specialist can't silently miss init.
|
||||
# endregion
|
||||
from src.dreamer.specialists import BaseSpecialist
|
||||
|
||||
for specialist in walk_subclasses(BaseSpecialist):
|
||||
for token_type in TokenTypes:
|
||||
self._touch(
|
||||
dreamer_tokens_processed_counter,
|
||||
specialist_name=specialist.name,
|
||||
token_type=token_type.value,
|
||||
)
|
||||
# ai: init at 0 so the gauge is visible before its first per-replica refresh
|
||||
self.set_message_embeddings_pending(count=0)
|
||||
|
||||
def set_telemetry_buffer_size(self, *, size: int) -> None:
|
||||
try:
|
||||
telemetry_buffer_size_gauge.labels().set(size)
|
||||
except Exception as e:
|
||||
self._handle_metric_error("set_telemetry_buffer_size", e)
|
||||
|
||||
def set_message_embeddings_pending(self, *, count: int) -> None:
|
||||
try:
|
||||
message_embeddings_pending_gauge.labels().set(count)
|
||||
except Exception as e:
|
||||
self._handle_metric_error("set_message_embeddings_pending", e)
|
||||
|
||||
|
||||
prometheus_metrics = PrometheusMetrics()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from collections.abc import Awaitable, Callable, Generator
|
||||
from collections.abc import Awaitable, Callable, Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -6,6 +6,20 @@ from typing import Any, Generic, Literal, TypeVar
|
|||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def walk_subclasses(cls: type[T]) -> Iterator[type[T]]:
|
||||
"""Yield every subclass of ``cls``, recursively."""
|
||||
# region ai
|
||||
# ``type.__subclasses__()`` is direct-children-only, so a grandchild class is
|
||||
# silently invisible to it. Any registry that enumerates subclasses to decide
|
||||
# what to initialize or validate wants the transitive closure — otherwise
|
||||
# subclassing a concrete class is enough to slip past the check.
|
||||
# endregion
|
||||
for subclass in cls.__subclasses__():
|
||||
yield subclass
|
||||
yield from walk_subclasses(subclass)
|
||||
|
||||
|
||||
# Context variable for tracking current iteration in tool execution loop
|
||||
# This is used for telemetry to associate tool calls with their iteration
|
||||
_current_iteration: ContextVar[int] = ContextVar("current_iteration", default=0)
|
||||
|
|
|
|||
|
|
@ -17,21 +17,18 @@ from dataclasses import dataclass, field
|
|||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Literal, TypeVar, cast
|
||||
from typing import Any, Generic, TypeVar, cast
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from honcho import Honcho
|
||||
from honcho.api_types import SessionConfiguration, SummaryConfiguration
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from src.config import REASONING_LEVELS, ReasoningLevel
|
||||
from src.telemetry.metrics_collector import MetricsCollector
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid reasoning levels for dialectic chat
|
||||
ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"]
|
||||
REASONING_LEVELS: list[str] = ["minimal", "low", "medium", "high", "max"]
|
||||
|
||||
# Type variable for result types
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
"""The pending-embeddings backlog gauge must be refreshed per-replica.
|
||||
|
||||
These tests pin both halves: the scheduler loop drives the refresh, and the
|
||||
queue-driven reconciliation cycle does not.
|
||||
"""
|
||||
# region ai
|
||||
# ``message_embeddings_pending`` reports a DB-global count, so it is the one gauge
|
||||
# here whose value is service-wide rather than per-process. It is also zero-
|
||||
# initialized at startup, which makes a missing refresh actively harmful: a replica
|
||||
# that never measured the backlog would export a confident, permanently-healthy 0.
|
||||
# So the count is driven from ``ReconcilerScheduler._scheduler_loop`` (runs on every
|
||||
# replica, every interval), NOT from ``run_vector_reconciliation_cycle`` (runs off
|
||||
# the queue behind work-unit dedup, so exactly one replica per cycle executes it).
|
||||
# endregion
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src.reconciler import scheduler as scheduler_module
|
||||
from src.reconciler import sync_vectors
|
||||
from src.reconciler.scheduler import ReconcilerScheduler
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_scheduler_singleton(): # pyright: ignore[reportUnusedFunction]
|
||||
ReconcilerScheduler.reset_singleton()
|
||||
yield
|
||||
ReconcilerScheduler.reset_singleton()
|
||||
|
||||
|
||||
async def test_scheduler_loop_refreshes_backlog_gauge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Every scheduler iteration refreshes the gauge, on every replica.
|
||||
|
||||
Patched at the scheduler's own reference so this asserts the call site, not
|
||||
just that the function exists.
|
||||
"""
|
||||
calls = 0
|
||||
refreshed = asyncio.Event()
|
||||
|
||||
async def _fake_refresh() -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
refreshed.set()
|
||||
|
||||
# region ai
|
||||
# Patched onto the class, so it is invoked as a bound method — it needs the
|
||||
# ``self`` parameter or the call raises TypeError, which ``_scheduler_loop`` would
|
||||
# then swallow, leaving this guard silently inert.
|
||||
# endregion
|
||||
async def _never_enqueue(_self: object, _task: object) -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler_module, "record_pending_embeddings_backlog", _fake_refresh
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ReconcilerScheduler, "_try_enqueue_task", _never_enqueue, raising=True
|
||||
)
|
||||
|
||||
scheduler = ReconcilerScheduler()
|
||||
await scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(refreshed.wait(), timeout=5.0)
|
||||
finally:
|
||||
await scheduler.shutdown()
|
||||
|
||||
assert calls >= 1, "scheduler loop never refreshed the backlog gauge"
|
||||
|
||||
|
||||
def test_reconciliation_cycle_does_not_drive_the_gauge() -> None:
|
||||
"""The queue-driven cycle must not be the thing that sets the gauge."""
|
||||
# region ai
|
||||
# If the refresh moves back into ``run_vector_reconciliation_cycle``, only the
|
||||
# replica that wins the ``sync_vectors`` work unit would ever measure the backlog,
|
||||
# and the zero-init would go back to lying on all the others.
|
||||
#
|
||||
# Structural guard: the cycle is a long DB-driven coroutine, so this inspects the
|
||||
# global names it references rather than executing it.
|
||||
# endregion
|
||||
assert hasattr(sync_vectors, "record_pending_embeddings_backlog")
|
||||
|
||||
referenced = sync_vectors.run_vector_reconciliation_cycle.__code__.co_names
|
||||
assert "record_pending_embeddings_backlog" not in referenced
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
"""Tests for startup zero-initialization of bounded-label metrics.
|
||||
|
||||
Asserts that:
|
||||
- bounded-label counter children are materialized at 0 before any event,
|
||||
- high-cardinality / impossible label combinations are deliberately NOT,
|
||||
- per-process init doesn't materialize the other process's counters,
|
||||
- the explicit registries stay in sync with the source of truth (drift guards).
|
||||
"""
|
||||
# region ai
|
||||
# Reads use ``REGISTRY.get_sample_value`` (returns the value if a series exists,
|
||||
# ``None`` if it does not) rather than ``counter.labels(...)``, because ``.labels``
|
||||
# would itself materialize the child and destroy the presence/absence signal.
|
||||
# endregion
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from src.config import REASONING_LEVELS, settings
|
||||
from src.dreamer.specialists import BaseSpecialist
|
||||
from src.telemetry.events import ALL_EVENT_TYPES, HIGH_VOLUME_EVENT_TYPES
|
||||
from src.telemetry.events.base import BaseEvent
|
||||
from src.telemetry.prometheus.metrics import (
|
||||
_DERIVER_TOKEN_COMBOS_BY_TASK, # pyright: ignore[reportPrivateUsage]
|
||||
DeriverComponents,
|
||||
DeriverTaskTypes,
|
||||
DialecticComponents,
|
||||
TokenTypes,
|
||||
prometheus_metrics,
|
||||
)
|
||||
from src.utils.types import walk_subclasses
|
||||
|
||||
|
||||
def unique_ns(tag: str) -> str:
|
||||
"""A ``namespace`` label value no other test can have materialized under."""
|
||||
# region ai
|
||||
# Every assertion here reads the process-global ``REGISTRY``, which keeps a child
|
||||
# series for the rest of the session once anything materializes it. A shared
|
||||
# namespace (several other suites pin ``"test"``) would let another test's children
|
||||
# satisfy a presence assertion, or break an absence assertion, independently of
|
||||
# what the initializer under test actually did.
|
||||
# endregion
|
||||
return f"test_metric_zero_init_{tag}_{uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics_enabled(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
|
||||
"""Enable metrics under a namespace unique to the requesting test."""
|
||||
ns = unique_ns("enabled")
|
||||
monkeypatch.setattr("src.config.settings.METRICS.ENABLED", True)
|
||||
monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", ns)
|
||||
yield ns
|
||||
|
||||
|
||||
def sample(name: str, **labels: str) -> float | None:
|
||||
"""Value of a series if it exists, else None. Never materializes it.
|
||||
|
||||
Resolves the namespace from settings, so it always reads the unique one the
|
||||
active test pinned.
|
||||
"""
|
||||
ns = cast(str, settings.METRICS.NAMESPACE)
|
||||
return REGISTRY.get_sample_value(name, {"namespace": ns, **labels})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drift guards (pure logic — no registry). Adding an event type / token component
|
||||
# without updating the registry fails here, with a pointer to what to fix.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_event_types_registry_matches_subclasses():
|
||||
"""ALL_EVENT_TYPES must equal every BaseEvent subclass's _event_type.
|
||||
|
||||
If this fails you added/removed a BaseEvent subclass without updating
|
||||
ALL_EVENT_TYPES in src/telemetry/events/__init__.py — its Prometheus counter
|
||||
would not be zero-initialized. Update the registry.
|
||||
"""
|
||||
discovered = {
|
||||
event_type
|
||||
for cls in walk_subclasses(BaseEvent)
|
||||
if (event_type := getattr(cls, "_event_type", None)) is not None
|
||||
}
|
||||
assert set(ALL_EVENT_TYPES) == discovered
|
||||
assert len(ALL_EVENT_TYPES) == len(set(ALL_EVENT_TYPES)), "duplicate event types"
|
||||
|
||||
|
||||
def test_high_volume_registry_matches_subclasses():
|
||||
"""HIGH_VOLUME_EVENT_TYPES must equal the high_volume-classed subclasses."""
|
||||
discovered = {
|
||||
event_type
|
||||
for cls in walk_subclasses(BaseEvent)
|
||||
if (event_type := getattr(cls, "_event_type", None)) is not None
|
||||
and getattr(cls, "_volume_class", None) == "high_volume"
|
||||
}
|
||||
assert set(HIGH_VOLUME_EVENT_TYPES) == discovered
|
||||
assert set(HIGH_VOLUME_EVENT_TYPES) <= set(ALL_EVENT_TYPES)
|
||||
|
||||
|
||||
def test_deriver_token_combos_are_valid_and_complete():
|
||||
"""Every combo uses real enum values; the union across tasks covers every
|
||||
DeriverComponent; and no task enumerates an impossible pair.
|
||||
|
||||
Fails if a DeriverComponent/DeriverTaskType is added without deciding which
|
||||
task_type + token_type it pairs with in _DERIVER_TOKEN_COMBOS_BY_TASK.
|
||||
"""
|
||||
valid_token_types = {t.value for t in TokenTypes}
|
||||
valid_components = {c.value for c in DeriverComponents}
|
||||
valid_task_types = {t.value for t in DeriverTaskTypes}
|
||||
|
||||
assert set(_DERIVER_TOKEN_COMBOS_BY_TASK) == valid_task_types
|
||||
all_components: set[str] = set()
|
||||
for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items():
|
||||
assert task_type in valid_task_types
|
||||
for token_type, component in combos:
|
||||
assert token_type in valid_token_types
|
||||
assert component in valid_components
|
||||
# each task enumerates fewer than its cartesian product (no impossible pairs)
|
||||
assert len(combos) < len(valid_token_types) * len(valid_components)
|
||||
all_components.update(comp for _, comp in combos)
|
||||
|
||||
# every component is reachable via some task
|
||||
assert all_components == valid_components
|
||||
# previous_summary is summary-only: ingestion must NOT enumerate it
|
||||
ingestion = _DERIVER_TOKEN_COMBOS_BY_TASK[DeriverTaskTypes.INGESTION.value]
|
||||
assert (
|
||||
TokenTypes.INPUT.value,
|
||||
DeriverComponents.PREVIOUS_SUMMARY.value,
|
||||
) not in ingestion
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API-process zero-init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_api_init_materializes_event_type_children():
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
for event_type in ALL_EVENT_TYPES:
|
||||
assert sample("telemetry_events_emitted_total", type=event_type) is not None
|
||||
for event_type in HIGH_VOLUME_EVENT_TYPES:
|
||||
assert sample("telemetry_events_sampled_out_total", type=event_type) is not None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_api_init_materializes_dialectic_and_embed():
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
for token_type in TokenTypes:
|
||||
for level in REASONING_LEVELS:
|
||||
assert (
|
||||
sample(
|
||||
"dialectic_tokens_processed_total",
|
||||
token_type=token_type.value,
|
||||
component=DialecticComponents.TOTAL.value,
|
||||
reasoning_level=level,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert sample("embed_now_tasks_shed_total") is not None
|
||||
assert sample("embed_now_tasks_in_flight") == 0.0 # gauge, explicit .set(0)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_sampled_out_excludes_ground_truth_event_types():
|
||||
"""Ground-truth events can never be sampled out, so their sampled_out series
|
||||
must NOT be pre-created (they'd be permanently misleading zeros)."""
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
ground_truth = set(ALL_EVENT_TYPES) - set(HIGH_VOLUME_EVENT_TYPES)
|
||||
for event_type in ground_truth:
|
||||
assert sample("telemetry_events_sampled_out_total", type=event_type) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deriver-process zero-init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_deriver_init_materializes_token_and_backlog():
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
|
||||
for task_type, combos in _DERIVER_TOKEN_COMBOS_BY_TASK.items():
|
||||
for token_type, component in combos:
|
||||
assert (
|
||||
sample(
|
||||
"deriver_tokens_processed_total",
|
||||
task_type=task_type,
|
||||
token_type=token_type,
|
||||
component=component,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
# region ai
|
||||
# Specialist names are derived from the concrete BaseSpecialist subclasses here
|
||||
# too, rather than hardcoded: a hardcoded list would keep passing when a new
|
||||
# specialist is added (it only asserts presence), silently leaving it uncovered.
|
||||
# endregion
|
||||
specialist_names = {
|
||||
name
|
||||
for cls in walk_subclasses(BaseSpecialist)
|
||||
if (name := getattr(cls, "name", None)) is not None
|
||||
}
|
||||
assert {"deduction", "induction", "card_refresh"} <= specialist_names
|
||||
for specialist_name in specialist_names:
|
||||
assert (
|
||||
sample(
|
||||
"dreamer_tokens_processed_total",
|
||||
specialist_name=specialist_name,
|
||||
token_type=TokenTypes.INPUT.value,
|
||||
)
|
||||
is not None
|
||||
), f"specialist {specialist_name!r} was not zero-initialized"
|
||||
assert sample("message_embeddings_pending") == 0.0 # gauge zero-init
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_deriver_init_omits_impossible_token_combos():
|
||||
"""The cartesian product includes combos that never occur (e.g. output tokens
|
||||
with an input component). Those must not be materialized."""
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
|
||||
# output tokens never pair with an input component
|
||||
assert (
|
||||
sample(
|
||||
"deriver_tokens_processed_total",
|
||||
task_type=DeriverTaskTypes.INGESTION.value,
|
||||
token_type=TokenTypes.OUTPUT.value,
|
||||
component=DeriverComponents.PROMPT.value,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# previous_summary is summary-only — ingestion must not materialize it
|
||||
assert (
|
||||
sample(
|
||||
"deriver_tokens_processed_total",
|
||||
task_type=DeriverTaskTypes.INGESTION.value,
|
||||
token_type=TokenTypes.INPUT.value,
|
||||
component=DeriverComponents.PREVIOUS_SUMMARY.value,
|
||||
)
|
||||
is None
|
||||
)
|
||||
# base specialist is abstract and never emits — must not be materialized
|
||||
assert (
|
||||
sample(
|
||||
"dreamer_tokens_processed_total",
|
||||
specialist_name="base",
|
||||
token_type=TokenTypes.INPUT.value,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-cardinality counters are left open, and per-process isolation holds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_high_cardinality_counters_not_materialized():
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
|
||||
# no endpoint/workspace_name series fabricated
|
||||
assert (
|
||||
sample(
|
||||
"api_requests_total",
|
||||
method="GET",
|
||||
endpoint="/v3/does-not-exist",
|
||||
status_code="200",
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert sample("messages_created_total", workspace_name="nope_ws") is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_api_init_does_not_touch_deriver_counters():
|
||||
"""api-only init must not materialize or change a deriver-only counter.
|
||||
|
||||
Delta-based (before == after) so it's robust to prior tests having
|
||||
materialized the series.
|
||||
"""
|
||||
labels = dict(
|
||||
task_type=DeriverTaskTypes.INGESTION.value,
|
||||
token_type=TokenTypes.INPUT.value,
|
||||
component=DeriverComponents.PROMPT.value,
|
||||
)
|
||||
before = sample("deriver_tokens_processed_total", **labels)
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
after = sample("deriver_tokens_processed_total", **labels)
|
||||
assert before == after
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_deriver_init_does_not_touch_api_counters():
|
||||
"""The inverse: deriver-only init must not materialize an API-only counter.
|
||||
|
||||
Without this, a deriver-startup regression could silently fabricate API
|
||||
series (permanently-0 dialectic tokens on a process that never serves chat).
|
||||
"""
|
||||
labels = dict(
|
||||
token_type=TokenTypes.INPUT.value,
|
||||
component=DialecticComponents.TOTAL.value,
|
||||
reasoning_level=REASONING_LEVELS[0],
|
||||
)
|
||||
before = sample("dialectic_tokens_processed_total", **labels)
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="deriver")
|
||||
after = sample("dialectic_tokens_processed_total", **labels)
|
||||
assert before == after
|
||||
# the API-process embed_now counters are equally off-limits
|
||||
assert sample("embed_now_tasks_shed_total") is None
|
||||
assert sample("embed_now_tasks_in_flight") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# telemetry_events_dropped: per-emitter child materialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("metrics_enabled")
|
||||
def test_dropped_counter_children_materialized():
|
||||
prometheus_metrics.initialize_telemetry_dropped_metrics(
|
||||
reasons=["buffer_full", "send_failed"]
|
||||
)
|
||||
assert sample("telemetry_events_dropped_total", reason="buffer_full") is not None
|
||||
assert sample("telemetry_events_dropped_total", reason="send_failed") is not None
|
||||
|
||||
|
||||
def test_dropped_counter_init_noop_when_metrics_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""The per-emitter initializer must no-op when metrics are disabled."""
|
||||
# region ai
|
||||
# The enabled/disabled pair above and below this line exists for
|
||||
# ``initialize_bounded_metrics`` (see ``test_init_noop_when_metrics_disabled``);
|
||||
# without this test the sibling initializer had only the enabled half, so its
|
||||
# ``METRICS.ENABLED`` guard could be deleted with the suite staying green. The
|
||||
# unique namespace is what makes the absence assertion mean anything — the enabled
|
||||
# test above materializes these same two reason values under a different one.
|
||||
# endregion
|
||||
monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False)
|
||||
monkeypatch.setattr(
|
||||
"src.config.settings.METRICS.NAMESPACE", unique_ns("dropped_disabled")
|
||||
)
|
||||
prometheus_metrics.initialize_telemetry_dropped_metrics(
|
||||
reasons=["buffer_full", "send_failed"]
|
||||
)
|
||||
assert sample("telemetry_events_dropped_total", reason="buffer_full") is None
|
||||
assert sample("telemetry_events_dropped_total", reason="send_failed") is None
|
||||
|
||||
|
||||
def test_init_noop_when_metrics_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
"""With metrics disabled, init must not fabricate series for a fresh label."""
|
||||
monkeypatch.setattr("src.config.settings.METRICS.ENABLED", False)
|
||||
monkeypatch.setattr("src.config.settings.METRICS.NAMESPACE", unique_ns("disabled"))
|
||||
prometheus_metrics.initialize_bounded_metrics(instance_type="api")
|
||||
assert sample("telemetry_events_emitted_total", type="message.created") is None
|
||||
Loading…
Reference in New Issue