feat: record each call's tenant and caller-defined dimensions
Both telemetry backends gain tenant_id and meta at the end of the
column list, and TelemetryEmitter fills them from the request. The two
halves ship together because the emitter is the only caller of
record_llm_call: adding the columns without filling them leaves every
row short of two keys, and the backends read those keys outside their
try block, so the KeyError degrades to a warning and the whole table
stops filling.
The columns are appended, never inserted. An old table can only gain
columns through ALTER, which puts them last; a new table built from the
DDL would put them wherever the DDL says. Anywhere but the end and the
two paths produce different physical column orders, while the INSERT
uses positional placeholders.
The two backends spell the default differently for different reasons.
SQLite refuses a NOT NULL column without a non-NULL constant default
outright, so the default is what makes the backfill legal at all. On
Postgres a non-volatile constant default is what keeps the ALTER from
rewriting the table, and NOT NULL DEFAULT '' is what keeps old rows out
of the black hole a NULL tenant_id falls into under an RLS policy.
Normalisation happens in the emitter, not the recorder, matching how
canonical_sampling_json already settles the sampling column: None
becomes the empty string, an empty mapping becomes the literal '{}'.
Keys are sorted so one set of dimensions serialises identically on
every row, and allow_nan=False is a second gate behind the entry
validation -- json.dumps would otherwise write a bare NaN, which JSONB
rejects, and the failed insert would be swallowed as a warning.
All three emit entry points read the request. Cache hits read it too,
rather than the replayed response: the dimensions answer who made this
call, not who made the one whose result is being replayed.
This commit is contained in:
@@ -40,6 +40,8 @@ _EXPECTED_COLUMNS = [
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
|
||||
@@ -104,11 +106,38 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
||||
"model_reported": None,
|
||||
"sampling": None,
|
||||
"reasoning_tokens": None,
|
||||
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
|
||||
"tenant_id": "",
|
||||
"meta": "{}",
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
|
||||
|
||||
class TestBackendColumnParity:
|
||||
"""两个后端的 `_COLUMNS` 必须逐字同名同序(issue #11)。
|
||||
|
||||
emitter 只组装一份 `fields`,两个后端各自按自己的 `_COLUMNS` 取值;两份清单
|
||||
一旦分叉,同一次调用在 SQLite 上写得进、在 PG 上抛 KeyError 被降级吞掉,
|
||||
差异只在换后端时才暴露。列**序**同样断言: INSERT 用位置占位符,顺序错位
|
||||
会把值写进错误的列而不报错。
|
||||
"""
|
||||
|
||||
def test_two_backends_agree_on_columns(self):
|
||||
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
|
||||
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||
|
||||
assert SQLITE_COLUMNS == PG_COLUMNS
|
||||
|
||||
def test_caller_dimensions_are_appended_last(self):
|
||||
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
||||
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
|
||||
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||
|
||||
assert SQLITE_COLUMNS[-2:] == ("tenant_id", "meta")
|
||||
assert PG_COLUMNS[-2:] == ("tenant_id", "meta")
|
||||
|
||||
|
||||
class TestSQLiteRecorder:
|
||||
async def test_schema_has_frozen_columns(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
@@ -327,6 +356,8 @@ class TestPostgresBackfillDiscipline:
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -379,6 +410,8 @@ class TestPostgresTableProbe:
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def _recorder(self, conn):
|
||||
@@ -613,6 +646,110 @@ class TestEmitterSamplingColumn:
|
||||
assert rec.rows[0]["sampling"] is None
|
||||
|
||||
|
||||
class TestEmitterCallerDimensions:
|
||||
"""issue #11: 三个 emit 入口统一从 `request` 读维度,`_record` 落库前归一化。
|
||||
|
||||
维度只有一个读取点(`request`),否则同一列在三种行里口径分叉——那正是
|
||||
"遥测调用点收敛为单一 helper"这条铁律要防的形态。
|
||||
"""
|
||||
|
||||
_META = {"z_last": "z", "a_first": 1, "m_mid": True}
|
||||
_REQ_A = ChatRequest(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
session_id="sess-1",
|
||||
tenant_id="tenant-a",
|
||||
meta=_META,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"])
|
||||
async def test_every_entry_point_carries_the_dimensions(self, emit):
|
||||
"""三条路径写出的行都必须带维度: 漏掉任一条,该租户的账就永远对不上。"""
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec)
|
||||
if emit == "attempt":
|
||||
await emitter.emit_attempt(
|
||||
request=self._REQ_A,
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(),
|
||||
error=None,
|
||||
)
|
||||
elif emit == "cache_hit":
|
||||
await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True))
|
||||
else:
|
||||
await emitter.emit_terminal_failure(
|
||||
request=self._REQ_A, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
row = rec.rows[0]
|
||||
assert row["tenant_id"] == "tenant-a"
|
||||
assert json.loads(row["meta"]) == self._META
|
||||
|
||||
async def test_cache_hit_records_the_current_caller_not_the_cached_one(self):
|
||||
"""缓存命中行的维度是"本次由谁发起",不是历史那次——最容易实现反的一处。
|
||||
|
||||
历史那次由租户 B 发起并把响应留在了缓存里;本次由租户 A 发起并命中。
|
||||
若读了历史那次的归属,租户 A 的调用会记到 B 头上,而 A 的账面凭空少一行
|
||||
——两个租户的账同时错,且错得没有任何报错。
|
||||
"""
|
||||
historical = ChatRequest(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tenant_id="tenant-b",
|
||||
meta={"batch": "old-batch"},
|
||||
)
|
||||
rec = _MemoryRecorder()
|
||||
mw = TelemetryMW(TelemetryEmitter(rec))
|
||||
|
||||
async def terminal(request):
|
||||
# 缓存层回放的是历史那次的响应对象(其 call_id 属于 historical 那次)
|
||||
return _resp(cache_hit=True, latency_ms=0, call_id="cache-cid")
|
||||
|
||||
assert historical.tenant_id == "tenant-b" # 历史归属确实不同,否则本用例是空转
|
||||
await mw(self._REQ_A, terminal)
|
||||
|
||||
row = rec.rows[0]
|
||||
assert row["cache_hit"] is True
|
||||
assert row["tenant_id"] == "tenant-a"
|
||||
assert "old-batch" not in row["meta"]
|
||||
|
||||
async def test_absent_dimensions_land_as_sentinels(self):
|
||||
"""未传维度落哨兵值: `tenant_id` 空串、`meta` 字面量 `'{}'`,都不是 NULL。
|
||||
|
||||
NULL 的 `tenant_id` 在 PG 的 RLS policy 下对所有人永久不可见(设计 §4.4),
|
||||
空串则可用一条 SQL 审计出还有多少行未归属;`meta` 同理,`'{}'` 可被
|
||||
JSON 函数直接查询,NULL 则要每条查询都额外判空。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec).emit_attempt(
|
||||
request=_REQ, # tenant_id=None, meta={}
|
||||
source=_source(),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(),
|
||||
error=None,
|
||||
)
|
||||
row = rec.rows[0]
|
||||
assert row["tenant_id"] == ""
|
||||
assert row["meta"] == "{}"
|
||||
|
||||
async def test_meta_is_serialized_with_sorted_keys(self):
|
||||
"""键序固定,同一份维度在任意两行里字节一致,可直接做等值比对与去重。"""
|
||||
rec = _MemoryRecorder()
|
||||
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||
request=self._REQ_A, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
assert list(json.loads(rec.rows[0]["meta"])) == ["a_first", "m_mid", "z_last"]
|
||||
|
||||
async def test_non_ascii_meta_stays_readable(self):
|
||||
"""`ensure_ascii=False`: 中文维度按原文落库,而非 `\\uXXXX` 转义串。"""
|
||||
rec = _MemoryRecorder()
|
||||
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], meta={"dept": "研发"})
|
||||
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||
request=req, call_id="c", latency_ms=1, error="dead"
|
||||
)
|
||||
assert "研发" in rec.rows[0]["meta"]
|
||||
|
||||
|
||||
class TestCostWithCachedTier:
|
||||
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user