Billing product model finalized (Deck #281): bill pages externally, record tokens internally. Rename the data-plane metric literals to match the now- canonical contract (Deck #284) — the control plane's METRIC_EVENT_NAMES is already renamed, so the old names would be unmapped and never sync to Stripe. Rename (values unchanged): - embeddings_queries → tokens_embedded (value = real token count, already emitted by this PR; the unit upstream providers bill on). - pages_chunks → pages_embedded (value kept as len(chunk_texts) interim; TODO(#282): real normalized "pages indexed" count — real pages for paginated types, chars/tokens-per-page constant otherwise — is deferred to the instrumentation card, this only lands the name/contract). - All literals, log strings, docstrings, comments, the migration comment, and tests renamed; grep confirms zero old strings remain. Observability (new): export embedding token cost to Prometheus as astrolabe_embedding_tokens_total{provider,operation} (operation = index|query) so the billed cost unit is visible in Grafana, not just the per-tenant billing DB. Dedicated counter (doesn't inflate the existing chunk/request metrics) and always-on (independent of USAGE_METERING_ENABLED, so OSS/self-host gets it). Wired on both the indexing batch embed and the search query embed (query inside the per-request cache-miss branch, so reused embeddings aren't double-counted). Note: the rename orphans any pre-existing embeddings_queries/pages_chunks rows in tenant app DBs (CP no longer maps them) — acceptable; pipeline is inert with throwaway dev/sandbox data. Deck #284 (folded into PR #875). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
"""Unit tests for embedding observability.
|
|
|
|
Covers:
|
|
1. ``Settings.get_embedding_provider_family()`` — the single source of truth for
|
|
the ``provider`` metric label / span attribute — across provider configs.
|
|
2. The ``record_embedding`` helper — that it increments the right
|
|
``astrolabe_embedding_*`` series and skips the throughput counters on error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.config import Settings
|
|
from nextcloud_mcp_server.observability.metrics import (
|
|
record_embedding,
|
|
record_embedding_tokens,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
# ``metric_sample`` is provided as a shared fixture in tests/unit/conftest.py.
|
|
|
|
|
|
class TestProviderFamily:
|
|
"""Provider-family detection mirrors ProviderRegistry priority."""
|
|
|
|
def test_bedrock(self):
|
|
assert (
|
|
Settings(aws_region="us-east-1").get_embedding_provider_family()
|
|
== "bedrock"
|
|
)
|
|
|
|
def test_openai(self):
|
|
settings = Settings(
|
|
openai_api_key="sk-test",
|
|
aws_region=None,
|
|
bedrock_embedding_model=None,
|
|
bedrock_generation_model=None,
|
|
)
|
|
assert settings.get_embedding_provider_family() == "openai"
|
|
|
|
def test_mistral(self):
|
|
settings = Settings(
|
|
mistral_api_key="m-test",
|
|
aws_region=None,
|
|
bedrock_embedding_model=None,
|
|
bedrock_generation_model=None,
|
|
openai_api_key=None,
|
|
)
|
|
assert settings.get_embedding_provider_family() == "mistral"
|
|
|
|
def test_ollama(self):
|
|
settings = Settings(
|
|
ollama_base_url="http://localhost:11434",
|
|
aws_region=None,
|
|
bedrock_embedding_model=None,
|
|
bedrock_generation_model=None,
|
|
openai_api_key=None,
|
|
mistral_api_key=None,
|
|
)
|
|
assert settings.get_embedding_provider_family() == "ollama"
|
|
|
|
def test_simple_fallback(self):
|
|
settings = Settings(
|
|
aws_region=None,
|
|
bedrock_embedding_model=None,
|
|
bedrock_generation_model=None,
|
|
openai_api_key=None,
|
|
mistral_api_key=None,
|
|
ollama_base_url=None,
|
|
)
|
|
assert settings.get_embedding_provider_family() == "simple"
|
|
|
|
def test_gateway_uses_model_prefix(self):
|
|
settings = Settings(
|
|
embedding_provider="gateway",
|
|
embedding_gateway_url="https://gateway:8080",
|
|
embedding_gateway_model="mistral/mistral-embed",
|
|
)
|
|
assert settings.get_embedding_provider_family() == "mistral"
|
|
|
|
|
|
class TestRecordEmbedding:
|
|
def test_dense_success_increments_throughput(self, metric_sample):
|
|
labels = {"kind": "dense", "provider": "uttest-prov"}
|
|
before_chunks = metric_sample("astrolabe_embedding_chunks_total", labels)
|
|
before_chars = metric_sample("astrolabe_embedding_chars_total", labels)
|
|
before_req = metric_sample(
|
|
"astrolabe_embedding_requests_total", {**labels, "status": "success"}
|
|
)
|
|
|
|
record_embedding("dense", "uttest-prov", 0.42, chunks=12, chars=3400)
|
|
|
|
assert metric_sample(
|
|
"astrolabe_embedding_chunks_total", labels
|
|
) == pytest.approx(before_chunks + 12)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_chars_total", labels
|
|
) == pytest.approx(before_chars + 3400)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_requests_total", {**labels, "status": "success"}
|
|
) == pytest.approx(before_req + 1)
|
|
assert (
|
|
metric_sample(
|
|
"astrolabe_embedding_duration_seconds_count",
|
|
{**labels, "status": "success"},
|
|
)
|
|
>= 1
|
|
)
|
|
|
|
def test_sparse_error_skips_throughput(self, metric_sample):
|
|
labels = {"kind": "sparse", "provider": "bm25-uttest"}
|
|
record_embedding(
|
|
"sparse", "bm25-uttest", 0.1, chunks=5, chars=100, status="error"
|
|
)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_chunks_total", labels
|
|
) == pytest.approx(0.0)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_chars_total", labels
|
|
) == pytest.approx(0.0)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_requests_total", {**labels, "status": "error"}
|
|
) == pytest.approx(1.0)
|
|
|
|
|
|
class TestRecordEmbeddingTokens:
|
|
"""astrolabe_embedding_tokens_total — token cost split by index/query."""
|
|
|
|
def test_index_increments_by_token_count(self, metric_sample):
|
|
labels = {"provider": "tok-prov", "operation": "index"}
|
|
before = metric_sample("astrolabe_embedding_tokens_total", labels)
|
|
record_embedding_tokens("tok-prov", "index", 4242)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_tokens_total", labels
|
|
) == pytest.approx(before + 4242)
|
|
|
|
def test_query_operation_is_separate_series(self, metric_sample):
|
|
labels = {"provider": "tok-prov", "operation": "query"}
|
|
before = metric_sample("astrolabe_embedding_tokens_total", labels)
|
|
record_embedding_tokens("tok-prov", "query", 7)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_tokens_total", labels
|
|
) == pytest.approx(before + 7)
|
|
|
|
def test_zero_or_negative_is_noop(self, metric_sample):
|
|
labels = {"provider": "tok-noop", "operation": "index"}
|
|
record_embedding_tokens("tok-noop", "index", 0)
|
|
record_embedding_tokens("tok-noop", "index", -3)
|
|
assert metric_sample(
|
|
"astrolabe_embedding_tokens_total", labels
|
|
) == pytest.approx(0.0)
|