Skip to content

Commit 86330d1

Browse files
jstuart0claude
andcommitted
feat: telemetry agent — internal/usage rolling counters + Counts payload (CA-400 phase 2)
* New internal/usage package: RollingDayCounter primitive (clock-injectable, ResetForTest seam) with package-level QueriesCounter / ArtifactsCounter, both 30-day windows * QA ask call site (qa/pipeline.go) increments QueriesCounter alongside existing qa.CountAsk() (preserves qa_asks_total_14d field; no consolidation) * New markArtifactReady wrapper in graphql layer: instruments three resolver paths (knowledge_generation_shared.go, knowledge_generation_cliff_notes.go, knowledge_generation_architecture_diagram.go). Each preserves its existing error policy (log-and-continue / return-error). Seed (SupersedeArtifact) and deepening paths intentionally bypass the wrapper. * TelemetryCounts() exposes "queries_30d" and "artifacts_generated_30d" keys to the collector via the existing Counts JSON blob (no ping struct change). * TELEMETRY.md: two new rows in the collected-fields table disclosing the process-local + resets-on-restart caveat. * docs/admin/telemetry-collector-qa-fields.md: sample SQL for the new keys with the 48h freshness-gate rationale (CA-400 Decision 6). * Makefile: new check-telemetry-disclosure target enforcing the grep gate. Plan: thoughts/shared/plans/active-2026-05-14-deliver-telemetry-metrics-expansion.md Ticket: CA-400 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9ee1ab commit 86330d1

14 files changed

Lines changed: 455 additions & 4 deletions

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
dev dev-web dev-go dev-worker clean migrate help integration-test test-integration smoke-test phase-gate ci \
55
test-livingwiki-integration test-livingwiki-smoke test-scripts \
66
benchmark-comprehension-fake benchmark-comprehension-local benchmark-comprehension-report \
7-
benchmark-report-quality-live
7+
benchmark-report-quality-live \
8+
check-telemetry-disclosure
89

910
GO_BIN = bin/sourcebridge
1011
GO_MIGRATE_BIN = bin/migrate
@@ -87,6 +88,13 @@ lint-worker:
8788
lint-vscode:
8889
cd plugins/vscode && npx eslint src --ext ts
8990

91+
# Telemetry disclosure gate: verify that every key shipped in the Counts
92+
# blob is documented in TELEMETRY.md. Add a grep line for each new key.
93+
check-telemetry-disclosure:
94+
@grep -q '`queries_30d`' TELEMETRY.md || (echo "TELEMETRY.md missing queries_30d disclosure"; exit 1)
95+
@grep -q '`artifacts_generated_30d`' TELEMETRY.md || (echo "TELEMETRY.md missing artifacts_generated_30d disclosure"; exit 1)
96+
@echo "telemetry disclosure: ok"
97+
9098
# Package the VS Code extension as a VSIX. The output file lands in
9199
# plugins/vscode/ and is gitignored. Use `install-vscode` to drop it
92100
# into your local VS Code afterward.

TELEMETRY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ information is ever collected.
2121
| `trash_purges_total` | `120` | Cumulative count of rows purged by the retention worker |
2222
| `trash_size_gauge` | `17` | Most recent sampled count of items currently in the trash |
2323
| `qa_asks_total_14d` | `342` | Rolling 14-day count of server-side QA (`/api/v1/ask`, `ask` mutation, MCP `ask_question`) invocations on this install. Zero when server-side QA is disabled. |
24+
| `queries_30d` | `342` | Rolling 30-day count of QA invocations (every `Orchestrator.Ask`) on this install. **Process-local; resets to zero when the agent process restarts; reported as the in-process sum at the moment of the ping.** Zero on fresh processes; grows over the next 30 days. |
25+
| `artifacts_generated_30d` | `17` | Rolling 30-day count of knowledge artifacts (cliff notes, architecture diagram, learning path, code tour, workflow story) that transitioned from GENERATING to READY via user-requested generation. **Excludes** field-guide seed artifacts and cliff-note section deepening (initialization/refresh, not new generation). **Process-local; resets to zero on agent process restart.** |
2426
| `qa_server_side` feature flag | `["qa_server_side"]` | Present in the `features` array when `SOURCEBRIDGE_QA_SERVER_SIDE_ENABLED=true`. Lets the public dashboard track orchestrator adoption. |
2527
| `clustering_enabled` | `true` | True when the `subsystem_clustering` capability is active on this installation. |
2628
| `cluster_count` | `12` | Number of clusters for the largest indexed repository. Zero when clustering has not run or no repos are indexed. |

cli/serve.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"github.com/sourcebridge/sourcebridge/internal/settings/livingwiki"
4444
"github.com/sourcebridge/sourcebridge/internal/telemetry"
4545
"github.com/sourcebridge/sourcebridge/internal/trash"
46+
"github.com/sourcebridge/sourcebridge/internal/usage"
4647
"github.com/sourcebridge/sourcebridge/internal/version"
4748
"github.com/sourcebridge/sourcebridge/internal/worker"
4849
"github.com/sourcebridge/sourcebridge/internal/worker/llmcall"
@@ -1436,6 +1437,12 @@ func (p *telemetryCountProvider) TelemetryCounts() (repos, users int, features [
14361437
counts[k] = v
14371438
}
14381439

1440+
// Merge in rolling 30-day usage counters (queries + artifacts generated).
1441+
// Process-local; resets to zero on agent restart.
1442+
for k, v := range usage.Counters() {
1443+
counts[k] = v
1444+
}
1445+
14391446
if qa.ServerSideEnabled() {
14401447
features = append(features, "qa_server_side")
14411448
}

cli/serve_telemetry_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 SourceBridge Contributors
3+
4+
package cli
5+
6+
import (
7+
"testing"
8+
9+
"github.com/sourcebridge/sourcebridge/internal/graph"
10+
"github.com/sourcebridge/sourcebridge/internal/usage"
11+
)
12+
13+
// TestTelemetryCountsIncludesUsageKeys verifies that TelemetryCounts returns
14+
// both "queries_30d" and "artifacts_generated_30d" keys in its Counts map,
15+
// and that the values reflect the package-level rolling counters.
16+
func TestTelemetryCountsIncludesUsageKeys(t *testing.T) {
17+
t.Cleanup(usage.ResetCountersForTest)
18+
19+
usage.QueriesCounter.Inc()
20+
usage.ArtifactsCounter.Inc()
21+
usage.ArtifactsCounter.Inc()
22+
23+
p := &telemetryCountProvider{store: graph.NewStore()}
24+
_, _, _, counts := p.TelemetryCounts()
25+
26+
if counts == nil {
27+
t.Fatal("TelemetryCounts returned nil counts map")
28+
}
29+
if got, ok := counts["queries_30d"]; !ok {
30+
t.Fatal("counts map missing key 'queries_30d'")
31+
} else if got != 1 {
32+
t.Fatalf("queries_30d: expected 1, got %d", got)
33+
}
34+
if got, ok := counts["artifacts_generated_30d"]; !ok {
35+
t.Fatal("counts map missing key 'artifacts_generated_30d'")
36+
} else if got != 2 {
37+
t.Fatalf("artifacts_generated_30d: expected 2, got %d", got)
38+
}
39+
}

docs/admin/telemetry-collector-qa-fields.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,47 @@ The first query is the "how many operators flipped the flag"
5151
adoption metric. The second is the activity metric — together they
5252
tell the story of a rollout.
5353

54+
## 5. CA-400: 30-day rolling counters for queries and artifacts
55+
56+
CA-400 (Phase 2) added two new keys to the `counts` blob:
57+
58+
- `queries_30d` — rolling 30-day count of QA invocations
59+
(`Orchestrator.Ask`). Process-local; resets on agent restart.
60+
- `artifacts_generated_30d` — rolling 30-day count of knowledge
61+
artifacts (cliff notes, architecture diagram, learning path, code
62+
tour, workflow story) that completed user-requested generation.
63+
Excludes seed/deepening paths. Process-local; resets on agent restart.
64+
65+
**Important**: `pings` is a latest-per-install snapshot table. The
66+
`counts` values on each row reflect the process-local state at the
67+
moment of that ping. Use a freshness gate (`last_seen > -48 hours`)
68+
when summing across installs — a 30-day window on `last_seen` would
69+
incorrectly aggregate 0–59 days of activity per the CA-400 Decision 6
70+
rationale.
71+
72+
```sql
73+
-- Total queries (30d ring) across currently-active installs
74+
-- "Active" = pinged within the last 48 hours (2× the 24h default cadence)
75+
SELECT COALESCE(
76+
SUM(CAST(json_extract(counts, '$.queries_30d') AS INTEGER)), 0
77+
) AS total_queries_30d
78+
FROM pings
79+
WHERE is_test = 0
80+
AND last_seen > datetime('now', '-48 hours');
81+
82+
-- Total artifacts generated (30d ring) across currently-active installs
83+
SELECT COALESCE(
84+
SUM(CAST(json_extract(counts, '$.artifacts_generated_30d') AS INTEGER)), 0
85+
) AS total_artifacts_generated_30d
86+
FROM pings
87+
WHERE is_test = 0
88+
AND last_seen > datetime('now', '-48 hours');
89+
```
90+
91+
Installs that have not yet sent the new keys will produce NULL from
92+
`json_extract`; `COALESCE(..., 0)` handles this gracefully so the
93+
aggregate is never NULL.
94+
5495
## 5. `README.md` — document the new keys
5596

5697
Append to whatever section enumerates counts:

internal/api/graphql/knowledge_generation_architecture_diagram.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ func (s architectureDiagramGenerationService) runGenerationPipeline(
227227
return err
228228
}
229229
}
230-
if err := r.Deps.KnowledgeStore.UpdateKnowledgeArtifactStatus(runCtx, artifact.ID, knowledgepkg.StatusReady); err != nil {
230+
if err := r.markArtifactReady(runCtx, artifact.ID); err != nil {
231231
return err
232232
}
233233
rt.ReportProgress(1.0, "ready", "AI architecture diagram ready", 0)

internal/api/graphql/knowledge_generation_cliff_notes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ func (s cliffNotesGenerationService) runGenerationPipeline(
432432
}
433433
}
434434

435-
if err := r.Deps.KnowledgeStore.UpdateKnowledgeArtifactStatus(runCtx, artifact.ID, knowledgepkg.StatusReady); err != nil {
435+
if err := r.markArtifactReady(runCtx, artifact.ID); err != nil {
436436
slog.Error("failed to mark cliff notes ready", "artifact_id", artifact.ID, "error", err)
437437
}
438438
if artifactUsesUnderstanding(generationMode) && depth != string(knowledgepkg.DepthSummary) {

internal/api/graphql/knowledge_generation_shared.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
graphstore "github.com/sourcebridge/sourcebridge/internal/graph"
1212
knowledgepkg "github.com/sourcebridge/sourcebridge/internal/knowledge"
1313
"github.com/sourcebridge/sourcebridge/internal/llm"
14+
"github.com/sourcebridge/sourcebridge/internal/usage"
1415
"github.com/sourcebridge/sourcebridge/internal/worker"
1516
)
1617

@@ -168,10 +169,27 @@ func runKnowledgePipeline(
168169
}
169170
}
170171

171-
if err := r.Deps.KnowledgeStore.UpdateKnowledgeArtifactStatus(runCtx, artifact.ID, knowledgepkg.StatusReady); err != nil {
172+
if err := r.markArtifactReady(runCtx, artifact.ID); err != nil {
172173
slog.Error("failed to mark "+cfg.artifactLabel+" ready", "artifact_id", artifact.ID, "error", err)
173174
}
174175
rt.ReportProgress(1.0, "ready", cfg.readyMessage, 0)
175176
slog.Info(cfg.artifactLabel+" generation complete", "artifact_id", artifact.ID)
176177
return nil
177178
}
179+
180+
// markArtifactReady transitions an artifact from GENERATING to READY and,
181+
// on success, increments the rolling 30-day artifacts-generated counter for
182+
// telemetry. It is the single instrumentation point for user-requested
183+
// artifact generation.
184+
//
185+
// The SupersedeArtifact paths (knowledge_seed.go and cliff-note deepening in
186+
// knowledge_support.go) are intentionally NOT routed through this wrapper —
187+
// they represent seed/refresh operations, not new user-requested generation.
188+
// See Decision 2 in the CA-400 plan for the full rationale.
189+
func (r *Resolver) markArtifactReady(ctx context.Context, artifactID string) error {
190+
if err := r.Deps.KnowledgeStore.UpdateKnowledgeArtifactStatus(ctx, artifactID, knowledgepkg.StatusReady); err != nil {
191+
return err
192+
}
193+
usage.ArtifactsCounter.Inc()
194+
return nil
195+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 SourceBridge Contributors
3+
4+
package graphql
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"github.com/sourcebridge/sourcebridge/internal/appdeps"
11+
knowledgepkg "github.com/sourcebridge/sourcebridge/internal/knowledge"
12+
"github.com/sourcebridge/sourcebridge/internal/usage"
13+
)
14+
15+
// TestMarkArtifactReady_IncrementsCounter verifies that a successful call to
16+
// markArtifactReady increments ArtifactsCounter by exactly 1, and that a
17+
// failed call (store returns an error for an unknown ID) does NOT increment
18+
// the counter.
19+
func TestMarkArtifactReady_IncrementsCounter(t *testing.T) {
20+
t.Cleanup(usage.ResetCountersForTest)
21+
22+
mem := knowledgepkg.NewMemStore()
23+
r := &Resolver{Deps: &appdeps.AppDeps{KnowledgeStore: mem}}
24+
25+
// Seed an artifact so UpdateKnowledgeArtifactStatus has something to update.
26+
artifact := &knowledgepkg.Artifact{
27+
ID: "art-001",
28+
Status: knowledgepkg.StatusGenerating,
29+
}
30+
if _, err := mem.StoreKnowledgeArtifact(context.Background(), artifact); err != nil {
31+
t.Fatalf("StoreKnowledgeArtifact: %v", err)
32+
}
33+
34+
// Happy path: markArtifactReady should succeed and increment the counter.
35+
if err := r.markArtifactReady(context.Background(), artifact.ID); err != nil {
36+
t.Fatalf("markArtifactReady: unexpected error: %v", err)
37+
}
38+
if got := usage.ArtifactsCounter.Total(); got != 1 {
39+
t.Fatalf("after success: expected ArtifactsCounter.Total() == 1, got %d", got)
40+
}
41+
42+
// Verify the artifact is actually in READY state.
43+
stored := mem.GetKnowledgeArtifact(context.Background(), artifact.ID)
44+
if stored == nil {
45+
t.Fatal("GetKnowledgeArtifact: returned nil after markArtifactReady")
46+
}
47+
if stored.Status != knowledgepkg.StatusReady {
48+
t.Fatalf("artifact status: expected %q, got %q", knowledgepkg.StatusReady, stored.Status)
49+
}
50+
51+
// Failure path: a non-existent artifact ID should return an error and
52+
// must NOT increment the counter.
53+
if err := r.markArtifactReady(context.Background(), "does-not-exist"); err == nil {
54+
t.Fatal("markArtifactReady on unknown ID: expected error, got nil")
55+
}
56+
if got := usage.ArtifactsCounter.Total(); got != 1 {
57+
t.Fatalf("after failure: expected ArtifactsCounter.Total() == 1 (unchanged), got %d", got)
58+
}
59+
}

internal/qa/pipeline.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
commonv1 "github.com/sourcebridge/sourcebridge/gen/go/common/v1"
1616
reasoningv1 "github.com/sourcebridge/sourcebridge/gen/go/reasoning/v1"
1717
"github.com/sourcebridge/sourcebridge/internal/llm/resolution"
18+
"github.com/sourcebridge/sourcebridge/internal/usage"
1819
"github.com/sourcebridge/sourcebridge/internal/worker"
1920
)
2021

@@ -405,6 +406,7 @@ func (o *Orchestrator) Ask(ctx context.Context, in AskInput) (*AskResult, error)
405406
// error path). Counter is process-local and read by the dashboard
406407
// ping at most once per 24h.
407408
CountAsk()
409+
usage.QueriesCounter.Inc()
408410

409411
started := time.Now()
410412
result := &AskResult{

0 commit comments

Comments
 (0)