Skip to content

Commit 92e810f

Browse files
committed
feat(analytics): break usage and cost down by calling extension
The caller identity from ADR-177 reached telemetry only, where no cost is recorded — so it could not answer the question it exists for. It now travels the cost path as well (ADR-178): source_extension is a column on tx_nrllm_service_usage and part of its daily aggregation key, fed from the same request metadata TelemetryMiddleware reads, so the cost row and the telemetry row cannot disagree about who called. The Analytics module gains a By-extension chart and a per-extension table (cost, requests, tokens). Calls that name no caller are listed as Unattributed rather than hidden. trackUsage() grows one optional trailing parameter; api-surface.txt is regenerated additively. Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
1 parent 4a03a46 commit 92e810f

18 files changed

Lines changed: 331 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77
## [Unreleased]
88

99
### Added
10+
- **Analytics answers "which extension spent what"** (ADR-178). The usage
11+
table carries the caller's extension key next to the money, so the
12+
Analytics module gains a *By extension* chart and a per-extension table
13+
with cost, requests and tokens. Calls that name no caller — wizard
14+
tasks, scheduler runs, anything unannotated — are listed as
15+
*Unattributed*. `UsageTrackerServiceInterface::trackUsage()` grows one
16+
optional trailing `$sourceExtension` parameter (additive); rows written
17+
before this change stay unattributed.
18+
1019
- **Agent-harness verification.** `docs/ARCHITECTURE.md` (component map + phpat dependency-rule summary), `docs/exec-plans/` scaffold, `Build/Scripts/verify-harness.sh`, and a `harness-verify.yml` workflow — a thin caller of the shared `script-check` reusable — that fails CI on an AGENTS.md line-budget or dead-reference regression.
1120

1221
- A caller can choose the correlation id its call is traced under, through

Classes/Controller/Backend/AnalyticsController.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public function indexAction(): ResponseInterface
7070
$byProvider = $this->analytics->getBreakdownByProvider($period->from, $period->to);
7171
$byModel = $this->analytics->getBreakdownByModel($period->from, $period->to);
7272
$byService = $this->analytics->getBreakdownByService($period->from, $period->to);
73+
$bySource = $this->analytics->getBreakdownBySourceExtension($period->from, $period->to);
7374

7475
$moduleTemplate->assignMultiple([
7576
'preset' => $period->preset,
@@ -82,6 +83,11 @@ public function indexAction(): ResponseInterface
8283
'byProvider' => $byProvider,
8384
'byModel' => $byModel,
8485
'byService' => $byService,
86+
// Who called: the extension key a consumer named via
87+
// withCallerSource() (ADR-178). The compatibility layer tags every
88+
// bridged third-party call, so this is the AI inventory of the
89+
// installation, priced.
90+
'bySource' => $bySource,
8591
'perUser' => $this->analytics->getPerUserUsage($period->from, $period->to),
8692
// Reads tx_nrllm_telemetry, not the usage table: the runs a sibling
8793
// configuration answered for after the requested one failed.
@@ -99,6 +105,7 @@ public function indexAction(): ResponseInterface
99105
'byProvider' => $byProvider,
100106
'byModel' => $byModel,
101107
'byService' => $byService,
108+
'bySource' => $bySource,
102109
], JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
103110
]);
104111

Classes/Provider/Middleware/UsageMiddleware.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,22 @@ private function track(
251251
taskUid: $taskUid,
252252
beUserUid: $beUserUid,
253253
countsAsRequest: $countsAsRequest,
254+
sourceExtension: $this->sourceExtension($context),
254255
);
255256
}
256257

258+
/**
259+
* The caller identity as {@see TelemetryMiddleware} reads it — same
260+
* metadata key, so the cost row and the telemetry row cannot disagree
261+
* about who called (ADR-178).
262+
*/
263+
private function sourceExtension(ProviderCallContext $context): string
264+
{
265+
$value = $context->metadata[TelemetryMiddleware::METADATA_SOURCE_EXTENSION] ?? null;
266+
267+
return is_string($value) ? $value : '';
268+
}
269+
257270
/**
258271
* One token figure as the PROVIDER reported it, or null where it reported
259272
* nothing (ADR-174).
@@ -301,6 +314,7 @@ private function trackSpecialized(ProviderCallContext $context, mixed $result):
301314
taskUid: $record->taskUid,
302315
beUserUid: $record->beUserUid,
303316
countsAsRequest: $record->countsAsRequest,
317+
sourceExtension: $this->sourceExtension($context),
304318
);
305319
}
306320

Classes/Service/UsageAnalyticsService.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
'service_provider',
4141
'model_id',
4242
'service_type',
43+
'source_extension',
4344
'model_uid',
4445
'configuration_uid',
4546
'task_uid',
@@ -117,6 +118,15 @@ public function getBreakdownByService(DateTimeInterface $from, DateTimeInterface
117118
return $this->breakdown('service_type', $from, $to);
118119
}
119120

121+
public function getBreakdownBySourceExtension(DateTimeInterface $from, DateTimeInterface $to): array
122+
{
123+
// Unattributed rows are the normal case for anything that does not
124+
// annotate (a wizard task, a scheduler run), so they get a label of
125+
// their own instead of the generic "unknown" the other breakdowns use
126+
// for a missing provider or model (ADR-178).
127+
return $this->breakdown('source_extension', $from, $to, 'unattributed');
128+
}
129+
120130
public function getTotalsGroupedBy(string $column, DateTimeInterface $from, DateTimeInterface $to): array
121131
{
122132
$this->assertGroupableColumn($column);
@@ -317,7 +327,7 @@ private function assertGroupableColumn(string $column): void
317327
/**
318328
* @return list<array{label: string, cost: float, requests: int, tokens: int}>
319329
*/
320-
private function breakdown(string $column, DateTimeInterface $from, DateTimeInterface $to): array
330+
private function breakdown(string $column, DateTimeInterface $from, DateTimeInterface $to, string $emptyLabel = 'unknown'): array
321331
{
322332
$this->assertGroupableColumn($column);
323333
$qb = $this->connectionPool->getQueryBuilderForTable(self::TABLE);
@@ -333,7 +343,7 @@ private function breakdown(string $column, DateTimeInterface $from, DateTimeInte
333343
foreach ($rows as $row) {
334344
$label = is_string($row[$column] ?? null) ? $row[$column] : '';
335345
if ($label === '') {
336-
$label = 'unknown';
346+
$label = $emptyLabel;
337347
}
338348

339349
$out[] = [

Classes/Service/UsageAnalyticsServiceInterface.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ public function getBreakdownByModel(DateTimeInterface $from, DateTimeInterface $
4444
*/
4545
public function getBreakdownByService(DateTimeInterface $from, DateTimeInterface $to): array;
4646

47+
/**
48+
* Cost, requests and tokens per calling extension — the `source_extension`
49+
* a consumer named via `AbstractOptions::withCallerSource()` (ADR-178).
50+
* Calls that named nobody are grouped under `unattributed`.
51+
*
52+
* @return list<array{label: string, cost: float, requests: int, tokens: int}>
53+
*/
54+
public function getBreakdownBySourceExtension(DateTimeInterface $from, DateTimeInterface $to): array;
55+
4756
/**
4857
* Sum cost/requests/tokens grouped by an internal column, keyed by that
4958
* column's value. $column MUST be a hardcoded internal column name

Classes/Service/UsageTrackerService.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,12 @@ public function trackUsage(
7070
int $taskUid = 0,
7171
?int $beUserUid = null,
7272
bool $countsAsRequest = true,
73+
string $sourceExtension = '',
7374
): void {
7475
$beUser = $beUserUid ?? $this->getCurrentBackendUserId();
76+
// Mirrors the telemetry column width; a longer claim is truncated
77+
// rather than rejected (ADR-178: attribution is a label, not a key).
78+
$sourceExtension = substr($sourceExtension, 0, 64);
7579
$today = strtotime('today');
7680
$now = time();
7781
// Sub-calls of a larger operation (e.g. a translation's language-detection
@@ -90,6 +94,10 @@ public function trackUsage(
9094
// indexed (config_lookup) and grouped on by the analytics module, so two
9195
// configurations on the same model must not merge into one row (which
9296
// would keep only the first configuration_uid and misattribute usage).
97+
// source_extension joins the key for the same reason (ADR-178): two
98+
// extensions calling one model on one day must stay two rows, or the
99+
// per-extension cost breakdown attributes everything to whoever wrote
100+
// the row first.
93101
$existingUid = $queryBuilder
94102
->select('uid')
95103
->from(self::TABLE)
@@ -101,6 +109,7 @@ public function trackUsage(
101109
$queryBuilder->expr()->eq('model_uid', $modelUid),
102110
$queryBuilder->expr()->eq('model_id', $queryBuilder->createNamedParameter($modelId)),
103111
$queryBuilder->expr()->eq('task_uid', $taskUid),
112+
$queryBuilder->expr()->eq('source_extension', $queryBuilder->createNamedParameter($sourceExtension)),
104113
$queryBuilder->expr()->eq('request_date', $today),
105114
)
106115
->executeQuery()
@@ -143,6 +152,7 @@ public function trackUsage(
143152
'model_uid' => $modelUid,
144153
'model_id' => $modelId,
145154
'task_uid' => $taskUid,
155+
'source_extension' => $sourceExtension,
146156
'be_user' => $beUser,
147157
'request_count' => $requestIncrement,
148158
'tokens_used' => $metrics['tokens'] ?? 0,

Classes/Service/UsageTrackerServiceInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ interface UsageTrackerServiceInterface
4747
* request row (e.g. the language-detection step of a
4848
* translation), so the metrics (tokens/cost) are still
4949
* aggregated but the request is counted only once.
50+
* @param string $sourceExtension Extension key the caller named via
51+
* `AbstractOptions::withCallerSource()`; '' when the call
52+
* is unattributed. Part of the daily aggregation key, so
53+
* per-extension cost stays separable (ADR-178).
5054
*/
5155
public function trackUsage(
5256
string $serviceType,
@@ -58,6 +62,7 @@ public function trackUsage(
5862
int $taskUid = 0,
5963
?int $beUserUid = null,
6064
bool $countsAsRequest = true,
65+
string $sourceExtension = '',
6166
): void;
6267

6368
/**

Documentation/Administration/Analytics.rst

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ continuous rather than skipping gaps.
8686
Breakdown charts
8787
================
8888

89-
Three bar charts split the window's usage along different axes:
89+
Four bar charts split the window's usage along different axes:
9090

9191
- **By provider** — cost and requests per ``service_provider``
9292
(OpenAI, Anthropic, Ollama, …).
@@ -95,6 +95,36 @@ Three bar charts split the window's usage along different axes:
9595
usage table, so it only reflects usage recorded after that change.
9696
- **By service** — cost and requests per service type (chat, vision,
9797
translation, speech, image).
98+
- **By extension** — cost and requests per calling extension, see
99+
:ref:`administration-analytics-per-extension`.
100+
101+
.. _administration-analytics-per-extension:
102+
103+
Per-extension table
104+
===================
105+
106+
A table lists cost, requests and tokens per **calling extension**, ordered
107+
by cost. It answers which piece of software spent what: a consumer names
108+
itself with ``AbstractOptions::withCallerSource()`` (:ref:`ADR-177
109+
<adr-177>`), and that name is stored on the usage row alongside the money
110+
(:ref:`ADR-178 <adr-178>`).
111+
112+
Where the entries come from:
113+
114+
- The compatibility layer `nr-llm-compat
115+
<https://github.com/netresearch/t3x-nr-llm-compat>`__ tags every call it
116+
reroutes, so an intercepted third-party extension (``ai_filemetadata``,
117+
``texter``, ``ns_t3ai``, …) shows up under its own extension key.
118+
- Any other consumer that annotates its calls appears the same way.
119+
- Everything else — wizard tasks, scheduler runs, playground calls and any
120+
consumer that does not annotate — is listed as **Unattributed**. That is
121+
the normal state, not an error.
122+
123+
.. note::
124+
The name is what the caller claims. Attribution is an inventory of the
125+
installation, not an access control: a caller can name itself anything,
126+
and nothing verifies it. Rows written before the column existed are
127+
unattributed — no migration invents an origin for them.
98128

99129
.. _administration-analytics-per-user:
100130

Resources/Private/Language/de.locallang.xlf

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,30 @@
12121212
<source>By service</source>
12131213
<target>Nach Dienst</target>
12141214
</trans-unit>
1215+
<trans-unit id="analytics.breakdown.source">
1216+
<source>By extension</source>
1217+
<target>Nach Extension</target>
1218+
</trans-unit>
1219+
<trans-unit id="analytics.source.title">
1220+
<source>Per calling extension</source>
1221+
<target>Pro aufrufender Extension</target>
1222+
</trans-unit>
1223+
<trans-unit id="analytics.source.description">
1224+
<source>What each extension spent through this installation. An extension appears here once it names itself on its calls; usage from wizards, scheduler runs and other unnamed callers is listed as unattributed.</source>
1225+
<target>Was jede Extension über diese Installation ausgegeben hat. Eine Extension erscheint hier, sobald sie sich bei ihren Aufrufen benennt; Verbrauch aus Assistenten, Scheduler-Läufen und anderen unbenannten Aufrufern steht unter "Nicht zugeordnet".</target>
1226+
</trans-unit>
1227+
<trans-unit id="analytics.source.unattributed">
1228+
<source>Unattributed</source>
1229+
<target>Nicht zugeordnet</target>
1230+
</trans-unit>
1231+
<trans-unit id="analytics.source.empty">
1232+
<source>No usage recorded in this period.</source>
1233+
<target>In diesem Zeitraum wurde kein Verbrauch erfasst.</target>
1234+
</trans-unit>
1235+
<trans-unit id="analytics.table.extension">
1236+
<source>Extension</source>
1237+
<target>Extension</target>
1238+
</trans-unit>
12151239
<trans-unit id="analytics.peruser.title">
12161240
<source>Per backend user</source>
12171241
<target>Pro Backend-Benutzer</target>

Resources/Private/Language/locallang.xlf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,24 @@
916916
<trans-unit id="analytics.breakdown.service">
917917
<source>By service</source>
918918
</trans-unit>
919+
<trans-unit id="analytics.breakdown.source">
920+
<source>By extension</source>
921+
</trans-unit>
922+
<trans-unit id="analytics.source.title">
923+
<source>Per calling extension</source>
924+
</trans-unit>
925+
<trans-unit id="analytics.source.description">
926+
<source>What each extension spent through this installation. An extension appears here once it names itself on its calls; usage from wizards, scheduler runs and other unnamed callers is listed as unattributed.</source>
927+
</trans-unit>
928+
<trans-unit id="analytics.source.unattributed">
929+
<source>Unattributed</source>
930+
</trans-unit>
931+
<trans-unit id="analytics.source.empty">
932+
<source>No usage recorded in this period.</source>
933+
</trans-unit>
934+
<trans-unit id="analytics.table.extension">
935+
<source>Extension</source>
936+
</trans-unit>
919937
<trans-unit id="analytics.peruser.title">
920938
<source>Per backend user</source>
921939
</trans-unit>

0 commit comments

Comments
 (0)