feat(metering): pages_embedded = real parsed page count

`pages_embedded` carried an interim chunk count (`len(chunk_texts)`,
TODO #282). Reframe it as a charge for *parsing* (PDF page extraction /
OCR) rather than a normalized content size:

- Parsed files (PDFs) record `pages_embedded` = real `page_count` from
  the document processor metadata.
- Text content (notes, deck cards, news items) is never parsed, carries
  no `page_count`, and records no `pages_embedded` row — only
  `tokens_embedded`. There is deliberately no chars/tokens-per-page
  constant; pages map 1:1 to parsed document pages.

`record_indexing_usage` now takes `page_count` and records the two
dimensions independently, gating `pages_embedded` on a truthy page count
(not the doc_type) so a future non-PDF parsed type stays correct. Stays
flag-gated + best-effort. Tests cover parsed-file, text-only, and
zero-page cases.

Deck #282 (board 8). Billing-model ADR corrected in
astrolabe-cloud-website docs/control-plane/usage-metering.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-08 16:58:31 +02:00
co-authored by Claude Opus 4.8
parent 20ce2a1845
commit 9c89a58a07
2 changed files with 102 additions and 33 deletions
+46 -26
View File
@@ -122,23 +122,30 @@ async def record_indexing_usage(
chunk_count: int,
token_count: int,
total_chars: int,
page_count: int | None,
) -> None:
"""Record the two billable usage events for one embedded document.
"""Record the billable usage events for one embedded document.
``pages_embedded`` is the buyer-facing "pages indexed" dimension;
``tokens_embedded`` is the embedding request's token count — the same metric
search records, so the meter bills embedding tokens whether they were
incurred indexing a document or embedding a query (Deck #67).
Two metered dimensions (Deck #67), recorded independently:
TODO(#282): ``pages_embedded`` currently carries the raw chunk count
(``len(chunk_texts)``) as an interim value. The real normalized "pages
indexed" count — real pages for paginated types (PDF/DOCX/PPT), a fixed
chars/tokens-per-page constant otherwise — is deferred to instrumentation
card #282; this code (card #284) only lands the metric name/contract.
- ``tokens_embedded`` — the embedding request's token count, recorded for
*every* embedded document. The same metric search records, so the meter
bills embedding tokens whether they were incurred indexing a document or
embedding a query.
- ``pages_embedded`` — a charge for **parsing** (PDF page extraction / OCR),
not a normalized content size. ``page_count`` is the real number of pages
the document processor parsed. Text content (notes, deck cards, news
items) is never parsed, carries no ``page_count``, and accrues **no**
``pages_embedded`` row — only ``tokens_embedded``. There is deliberately
no chars/tokens-per-page constant: pages map 1:1 to parsed document pages
(card #282).
Best-effort and flag-gated: a metering failure is logged and never breaks
indexing. No-op when metering is disabled or the document produced no chunks
(an empty batch embeds nothing and would only write zero-value rows).
``pages_embedded`` is additionally skipped when ``page_count`` is absent or
zero — gating on the page count itself (not the ``doc_type``) keeps this
correct if a future non-PDF parsed type starts reporting pages.
Privacy note: ``user_id`` stays tenant-local — the CP rollup aggregates
GROUP BY (day, metric) into ``usage_daily`` (no metadata column), so nothing
@@ -159,24 +166,27 @@ async def record_indexing_usage(
store = await UsageEventStore.shared()
# enabled=True: the guard above already confirmed the flag, so the store
# skips a second uncached Settings build per record (ADR-024).
# record_usage_event swallows its own write failures, so the two records
# are independent; if pages_embedded somehow raised mid-way,
# tokens_embedded would be skipped, leaving an unmatched pages_embedded
# row — acceptable under the (day, metric) SUM-aggregation billing model.
await store.record_usage_event(
# TODO(#282): value is the interim chunk count; switch to normalized
# real-page count when the per-page constant lands.
metric="pages_embedded",
value=chunk_count,
metadata=metadata,
enabled=True,
)
# record_usage_event swallows its own write failures, so the records are
# independent; one raising never blocks the other — acceptable under the
# (day, metric) SUM-aggregation billing model.
# tokens_embedded: recorded for every embedded document.
await store.record_usage_event(
metric="tokens_embedded",
value=token_count,
metadata=metadata,
enabled=True,
)
# pages_embedded: parsed pages only. Text content has no page_count, so
# skip it rather than write a zero-value row that would misrepresent a
# no-parse document as billable parsing work.
if page_count:
await store.record_usage_event(
metric="pages_embedded",
value=page_count,
metadata=metadata,
enabled=True,
)
except Exception:
# Reached only when shared()/store construction itself raises
# (record_usage_event swallows its own write failures). Metering is on,
@@ -912,11 +922,18 @@ async def _index_document(
# Export token consumption to Prometheus (always-on, independent of
# the billing flag) so Grafana sees indexing token cost.
record_embedding_tokens(provider, "index", embed_tokens)
# Usage metering (Deck #67): record the chunk volume +
# embedding-token count for this document. Best-effort and
# flag-gated; placed after the embedding succeeds so it can never
# affect the indexing path. See record_indexing_usage for the
# Usage metering (Deck #67): record the embedding-token count (all
# docs) and, for parsed files, the real parsed-page count. Best-
# effort and flag-gated; placed after the embedding succeeds so it
# can never affect the indexing path. ``page_count`` is set by the
# document processors for PDFs and absent for text types, so text
# content meters tokens only. See record_indexing_usage for the
# metric/privacy details.
#
# Narrow defensively: file_metadata values are loosely typed, so a
# malformed page_count meters as "no pages" rather than erroring on
# the indexing path.
raw_page_count = file_metadata.get("page_count")
await record_indexing_usage(
enabled=settings.usage_metering_enabled,
provider=provider,
@@ -926,6 +943,9 @@ async def _index_document(
chunk_count=len(chunk_texts),
token_count=embed_tokens,
total_chars=total_chars,
page_count=(
raw_page_count if isinstance(raw_page_count, int) else None
),
)
async def generate_sparse_embeddings():
+56 -7
View File
@@ -1,9 +1,12 @@
"""Unit tests for the indexing-path usage-metering helper (Deck #67).
``record_indexing_usage`` records the two billable events (``pages_embedded`` +
``tokens_embedded``) after a document's chunks are embedded. These cover the
value mapping, the flag/zero-chunk no-ops, and the best-effort failure path
without standing up the full document pipeline.
``record_indexing_usage`` records the billable events after a document's chunks
are embedded: ``tokens_embedded`` for every document, and ``pages_embedded``
only for parsed files (real ``page_count``). Text content (no ``page_count``)
meters tokens only — ``pages_embedded`` is a charge for parsing, not content
size (card #282). These cover the value mapping, the flag/zero-chunk no-ops, the
text-only path, and the best-effort failure path without standing up the full
document pipeline.
"""
from unittest.mock import AsyncMock, MagicMock
@@ -25,8 +28,8 @@ def store_spy(monkeypatch):
@pytest.mark.unit
async def test_records_pages_embedded_and_token_count(store_spy):
"""Both events fire: pages_embedded = chunk count, tokens_embedded = tokens."""
async def test_parsed_file_records_pages_and_tokens(store_spy):
"""A parsed PDF fires both events: pages_embedded = real page count."""
await processor.record_indexing_usage(
enabled=True,
provider="mistral",
@@ -36,11 +39,13 @@ async def test_records_pages_embedded_and_token_count(store_spy):
chunk_count=110,
token_count=4242,
total_chars=170826,
page_count=12,
)
calls = store_spy.record_usage_event.await_args_list
by_metric = {c.kwargs["metric"]: c.kwargs["value"] for c in calls}
assert by_metric == {"pages_embedded": 110, "tokens_embedded": 4242}
# pages_embedded is the real parsed-page count, NOT the chunk count.
assert by_metric == {"pages_embedded": 12, "tokens_embedded": 4242}
for c in calls:
# Hot-path fast-gate + tenant-local attribution metadata.
assert c.kwargs["enabled"] is True
@@ -50,6 +55,47 @@ async def test_records_pages_embedded_and_token_count(store_spy):
assert c.kwargs["metadata"]["doc_type"] == "file"
@pytest.mark.unit
async def test_text_doc_records_tokens_only(store_spy):
"""Unparsed text content (no page_count) meters tokens, never pages."""
await processor.record_indexing_usage(
enabled=True,
provider="mistral",
model="mistral-embed",
doc_type="note",
user_id="alice",
chunk_count=4,
token_count=512,
total_chars=7000,
page_count=None,
)
calls = store_spy.record_usage_event.await_args_list
by_metric = {c.kwargs["metric"]: c.kwargs["value"] for c in calls}
assert by_metric == {"tokens_embedded": 512}
assert "pages_embedded" not in by_metric
@pytest.mark.unit
async def test_zero_pages_skips_pages(store_spy):
"""page_count=0 (e.g. an empty/corrupt PDF) records tokens but no pages."""
await processor.record_indexing_usage(
enabled=True,
provider="mistral",
model="mistral-embed",
doc_type="file",
user_id="alice",
chunk_count=4,
token_count=99,
total_chars=1000,
page_count=0,
)
calls = store_spy.record_usage_event.await_args_list
by_metric = {c.kwargs["metric"]: c.kwargs["value"] for c in calls}
assert by_metric == {"tokens_embedded": 99}
@pytest.mark.unit
async def test_disabled_is_noop(store_spy):
"""Flag off → no store access, no events."""
@@ -62,6 +108,7 @@ async def test_disabled_is_noop(store_spy):
chunk_count=10,
token_count=20,
total_chars=5,
page_count=3,
)
store_spy.record_usage_event.assert_not_awaited()
@@ -78,6 +125,7 @@ async def test_zero_chunks_is_noop(store_spy):
chunk_count=0,
token_count=0,
total_chars=0,
page_count=3,
)
store_spy.record_usage_event.assert_not_awaited()
@@ -101,4 +149,5 @@ async def test_store_failure_is_swallowed(monkeypatch):
chunk_count=3,
token_count=7,
total_chars=9,
page_count=2,
)