Skip to content

Commit 174dba2

Browse files
Merge pull request #44 from Jeevan1351/fix-lines-of-code-net-count
2 parents a123a37 + 7f0802a commit 174dba2

8 files changed

Lines changed: 146 additions & 30 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
3131
| `opencode.session.count` | Counter | Incremented on each `session.created` event |
3232
| `opencode.token.usage` | Counter | Per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` |
3333
| `opencode.cost.usage` | Counter | USD cost per completed assistant message |
34-
| `opencode.lines_of_code.count` | Counter | Lines added/removed per `session.diff` event |
34+
| `opencode.lines_of_code.count` | Counter | **Gross positive churn, not a net total.** Emits the positive delta of `additions`/`deletions` since the previous `session.diff` for the same session; negative deltas (when opencode's cumulative `additions` or `deletions` shrinks vs. the last event) are dropped. Summing the counter therefore reports gross lines added/removed across forward transitions — it does *not* reconcile back to the session's current state after any revert (full or partial). Intra-message rewrites that opencode collapses in its per-message cumulative are not visible here at all. Use `opencode.lines_of_code.total` for the authoritative live cumulative. |
35+
| `opencode.lines_of_code.total` | Gauge | **Authoritative live cumulative lines added/removed for the session.** Refreshed on every `session.diff` with opencode's current cumulative value. Drops back to `0` if opencode reports a revert to baseline, and tracks partial reverts faithfully. Query this (not the counter) to answer "what does this session currently amount to". |
3536
| `opencode.commit.count` | Counter | Git commits detected via bash tool |
3637
| `opencode.tool.duration` | Histogram | Tool execution time in milliseconds |
3738
| `opencode.cache.count` | Counter | Cache activity per message: `type=cacheRead` or `type=cacheCreation` |
@@ -154,6 +155,9 @@ export OPENCODE_DISABLE_METRICS="retry.count"
154155

155156
# Disable multiple metrics
156157
export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count"
158+
159+
# Disable the new per-session cumulative gauge while keeping the delta counter
160+
export OPENCODE_DISABLE_METRICS="lines_of_code.total"
157161
```
158162

159163
#### opencode-only metrics

src/handlers/activity.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,64 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
22
import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk"
3-
import { isMetricEnabled } from "../util.ts"
3+
import { isMetricEnabled, setBoundedMap } from "../util.ts"
44
import type { HandlerContext } from "../types.ts"
55

6-
/** Records lines-added and lines-removed metrics for each file in the diff. */
6+
/**
7+
* Records lines-added/removed for a `session.diff` event. opencode publishes each event
8+
* with the cumulative session diff (first snapshot → latest), so we emit two instruments:
9+
* `opencode.lines_of_code.count` (Counter) receives only the *positive* per-event delta
10+
* for each dimension (additions, deletions). Negative deltas — opencode reporting a smaller
11+
* cumulative for a dimension than the previous event — are dropped, so the counter reports
12+
* gross positive churn and does not reconcile to net after any revert (full or partial).
13+
* `opencode.lines_of_code.total` (Gauge) mirrors opencode's current cumulative value on
14+
* every event and is the authoritative live view.
15+
*/
716
export function handleSessionDiff(e: EventSessionDiff, ctx: HandlerContext) {
817
const sessionID = e.properties.sessionID
918
const linesEnabled = isMetricEnabled("lines_of_code.count", ctx)
19+
const totalEnabled = isMetricEnabled("lines_of_code.total", ctx)
1020
let totalAdded = 0
1121
let totalRemoved = 0
1222
for (const fileDiff of e.properties.diff) {
13-
if (fileDiff.additions > 0) {
14-
if (linesEnabled) {
15-
ctx.instruments.linesCounter.add(fileDiff.additions, {
16-
...ctx.commonAttrs,
17-
"session.id": sessionID,
18-
type: "added",
19-
})
20-
}
21-
totalAdded += fileDiff.additions
23+
totalAdded += fileDiff.additions
24+
totalRemoved += fileDiff.deletions
25+
}
26+
27+
const prev = ctx.sessionDiffTotals.get(sessionID) ?? { additions: 0, deletions: 0 }
28+
const deltaAdded = totalAdded - prev.additions
29+
const deltaRemoved = totalRemoved - prev.deletions
30+
const nextTotals = { additions: totalAdded, deletions: totalRemoved }
31+
if (ctx.sessionDiffTotals.has(sessionID)) {
32+
// Existing session: update in place. Calling setBoundedMap on a full map would
33+
// evict an unrelated session here, and that session's next session.diff would
34+
// be treated as first-seen — reintroducing the cumulative double-count bug.
35+
ctx.sessionDiffTotals.set(sessionID, nextTotals)
36+
} else {
37+
setBoundedMap(ctx.sessionDiffTotals, sessionID, nextTotals)
38+
}
39+
40+
const baseAttrs = { ...ctx.commonAttrs, "session.id": sessionID }
41+
42+
if (linesEnabled) {
43+
if (deltaAdded > 0) {
44+
ctx.instruments.linesCounter.add(deltaAdded, { ...baseAttrs, type: "added" })
2245
}
23-
if (fileDiff.deletions > 0) {
24-
if (linesEnabled) {
25-
ctx.instruments.linesCounter.add(fileDiff.deletions, {
26-
...ctx.commonAttrs,
27-
"session.id": sessionID,
28-
type: "removed",
29-
})
30-
}
31-
totalRemoved += fileDiff.deletions
46+
if (deltaRemoved > 0) {
47+
ctx.instruments.linesCounter.add(deltaRemoved, { ...baseAttrs, type: "removed" })
3248
}
3349
}
34-
ctx.log("debug", "otel: lines_of_code counter incremented", {
50+
if (totalEnabled) {
51+
ctx.instruments.linesTotalGauge.record(totalAdded, { ...baseAttrs, type: "added" })
52+
ctx.instruments.linesTotalGauge.record(totalRemoved, { ...baseAttrs, type: "removed" })
53+
}
54+
55+
ctx.log("debug", "otel: lines_of_code metrics updated", {
3556
sessionID,
3657
files: e.properties.diff.length,
37-
added: totalAdded,
38-
removed: totalRemoved,
58+
deltaAdded,
59+
deltaRemoved,
60+
totalAdded,
61+
totalRemoved,
3962
})
4063
}
4164

src/handlers/session.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
8585
const sessionID = e.properties.sessionID
8686
const totals = ctx.sessionTotals.get(sessionID)
8787
ctx.sessionTotals.delete(sessionID)
88+
ctx.sessionDiffTotals.delete(sessionID)
8889
sweepSession(sessionID, ctx)
8990

9091
const attrs = { ...ctx.commonAttrs, "session.id": sessionID }
@@ -144,13 +145,16 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
144145
const rawID = e.properties.sessionID
145146
const sessionID = rawID ?? "unknown"
146147
const error = errorSummary(e.properties.error)
147-
if (rawID) ctx.sessionTotals.delete(rawID)
148+
const totals = rawID ? ctx.sessionTotals.get(rawID) : undefined
149+
if (rawID) {
150+
ctx.sessionTotals.delete(rawID)
151+
ctx.sessionDiffTotals.delete(rawID)
152+
}
148153
sweepSession(sessionID, ctx)
149154

150155
if (rawID) {
151156
const sessionSpan = ctx.sessionSpans.get(rawID)
152157
if (sessionSpan) {
153-
const totals = ctx.sessionTotals.get(rawID)
154158
if (totals) sessionSpan.setAttribute(AGENT_NAME, totals.agent)
155159
sessionSpan.setStatus({ code: SpanStatusCode.ERROR, message: error })
156160
sessionSpan.setAttribute("error", error)

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
9090
const pendingToolSpans = new Map()
9191
const pendingPermissions = new Map()
9292
const sessionTotals = new Map()
93+
const sessionDiffTotals = new Map()
9394
const sessionSpans = new Map()
9495
const messageSpans = new Map()
9596
const sessionInputs = new Map()
@@ -113,6 +114,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
113114
pendingToolSpans,
114115
pendingPermissions,
115116
sessionTotals,
117+
sessionDiffTotals,
116118
disabledMetrics,
117119
disabledTraces,
118120
tracer,

src/otel.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ export function createInstruments(prefix: string): Instruments {
154154
}),
155155
linesCounter: meter.createCounter(`${prefix}lines_of_code.count`, {
156156
unit: "{line}",
157-
description: "Count of lines of code added or removed",
157+
description: "Gross positive churn of lines added/removed across a session. Emits the positive delta vs. the previous session.diff; negative deltas (cumulative shrinkage) are dropped, so sums do not reconcile to net after any revert. Use lines_of_code.total for the authoritative live cumulative.",
158+
}),
159+
linesTotalGauge: meter.createGauge(`${prefix}lines_of_code.total`, {
160+
unit: "{line}",
161+
description: "Authoritative live cumulative lines added/removed for the current session. Mirrors opencode's session.diff cumulative value on every event; tracks partial and full reverts faithfully.",
158162
}),
159163
commitCounter: meter.createCounter(`${prefix}commit.count`, {
160164
unit: "{commit}",

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Counter, Histogram, Span, Tracer } from "@opentelemetry/api"
1+
import type { Counter, Gauge, Histogram, Span, Tracer } from "@opentelemetry/api"
22
import type { Logger as OtelLogger } from "@opentelemetry/api-logs"
33

44
/** Numeric priority map for log levels; higher value = higher severity. */
@@ -41,6 +41,7 @@ export type Instruments = {
4141
tokenCounter: Counter
4242
costCounter: Counter
4343
linesCounter: Counter
44+
linesTotalGauge: Gauge
4445
commitCounter: Counter
4546
toolDurationHistogram: Histogram
4647
cacheCounter: Counter
@@ -71,6 +72,7 @@ export type HandlerContext = {
7172
pendingToolSpans: Map<string, PendingToolSpan>
7273
pendingPermissions: Map<string, PendingPermission>
7374
sessionTotals: Map<string, SessionTotals>
75+
sessionDiffTotals: Map<string, { additions: number; deletions: number }>
7476
disabledMetrics: Set<string>
7577
disabledTraces: Set<string>
7678
tracer: Tracer

tests/handlers/activity.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,69 @@ describe("handleSessionDiff", () => {
6969
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "foo.ts", additions: 0, deletions: 0 }]), ctx)
7070
expect(counters.lines.calls).toHaveLength(0)
7171
})
72+
73+
test("linesCounter emits only positive deltas across multiple events", () => {
74+
const { ctx, counters } = makeCtx()
75+
// opencode publishes session.diff with the CUMULATIVE session total every event.
76+
// Cumulative sequence: 4, 9, 9, 11. Expected deltas: 4, 5, 0 (skipped), 2.
77+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 4, deletions: 0 }]), ctx)
78+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 9, deletions: 0 }]), ctx)
79+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 9, deletions: 0 }]), ctx)
80+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 11, deletions: 0 }]), ctx)
81+
const added = counters.lines.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value)
82+
expect(added).toEqual([4, 5, 2])
83+
expect(added.reduce((a, b) => a + b, 0)).toBe(11) // net, not 4+9+9+11=33
84+
})
85+
86+
test("linesCounter skips negative deltas (revert-to-baseline)", () => {
87+
const { ctx, counters } = makeCtx()
88+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 0 }]), ctx)
89+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 0, deletions: 0 }]), ctx)
90+
const added = counters.lines.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value)
91+
expect(added).toEqual([5])
92+
})
93+
94+
test("linesCounter is gross-only across a partial revert (additions shrink, deletions grow)", () => {
95+
// Cumulative goes {additions:10, deletions:0} -> {additions:5, deletions:5}.
96+
// Delta is {added:-5, removed:+5}. Negative added is skipped; positive removed
97+
// is emitted. Counter ends at added=10, removed=5 while the authoritative live
98+
// cumulative is added=5, removed=5 — the counter is GROSS, not net. Live
99+
// cumulative state is surfaced via linesTotalGauge (see next test).
100+
const { ctx, counters, gauges } = makeCtx()
101+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 10, deletions: 0 }]), ctx)
102+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 5 }]), ctx)
103+
104+
const added = counters.lines.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value)
105+
const removed = counters.lines.calls.filter((c) => c.attrs["type"] === "removed").map((c) => c.value)
106+
expect(added).toEqual([10])
107+
expect(removed).toEqual([5])
108+
109+
const gaugeAdded = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value)
110+
const gaugeRemoved = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "removed").map((c) => c.value)
111+
expect(gaugeAdded).toEqual([10, 5])
112+
expect(gaugeRemoved).toEqual([0, 5])
113+
})
114+
115+
test("linesTotalGauge records cumulative totals, including zero after revert", () => {
116+
const { ctx, gauges } = makeCtx()
117+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 2 }]), ctx)
118+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 0, deletions: 0 }]), ctx)
119+
const added = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value)
120+
const removed = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "removed").map((c) => c.value)
121+
expect(added).toEqual([5, 0])
122+
expect(removed).toEqual([2, 0])
123+
})
124+
125+
test("tracks deltas independently per session", () => {
126+
const { ctx, counters } = makeCtx()
127+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 3, deletions: 0 }]), ctx)
128+
handleSessionDiff(makeSessionDiff("ses_2", [{ file: "b.ts", additions: 7, deletions: 0 }]), ctx)
129+
handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 0 }]), ctx)
130+
const ses1 = counters.lines.calls.filter((c) => c.attrs["session.id"] === "ses_1").map((c) => c.value)
131+
const ses2 = counters.lines.calls.filter((c) => c.attrs["session.id"] === "ses_2").map((c) => c.value)
132+
expect(ses1).toEqual([3, 2])
133+
expect(ses2).toEqual([7])
134+
})
72135
})
73136

74137
describe("handleCommandExecuted", () => {

tests/helpers.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { HandlerContext, Instruments } from "../src/types.ts"
22
import type { Logger as OtelLogger, LogRecord } from "@opentelemetry/api-logs"
3-
import type { Counter, Histogram, Span, SpanOptions, Tracer, Context, SpanContext, SpanStatus, Attributes } from "@opentelemetry/api"
3+
import type { Counter, Gauge, Histogram, Span, SpanOptions, Tracer, Context, SpanContext, SpanStatus, Attributes } from "@opentelemetry/api"
44
import { SpanStatusCode, trace } from "@opentelemetry/api"
55

66
export type SpyCounter = {
@@ -13,6 +13,11 @@ export type SpyHistogram = {
1313
record(value: number, attrs?: Record<string, unknown>): void
1414
}
1515

16+
export type SpyGauge = {
17+
calls: Array<{ value: number; attrs: Record<string, unknown> }>
18+
record(value: number, attrs?: Record<string, unknown>): void
19+
}
20+
1621
export type SpyLogger = {
1722
records: LogRecord[]
1823
emit(record: LogRecord): void
@@ -57,6 +62,11 @@ function makeHistogram(): SpyHistogram {
5762
return spy
5863
}
5964

65+
function makeGauge(): SpyGauge {
66+
const spy: SpyGauge = { calls: [], record(v, a = {}) { spy.calls.push({ value: v, attrs: a }) } }
67+
return spy
68+
}
69+
6070
function makeLogger(): SpyLogger {
6171
const spy: SpyLogger = { records: [], emit(r) { spy.records.push(r) } }
6272
return spy
@@ -131,6 +141,7 @@ export type MockContext = {
131141
gauges: {
132142
sessionToken: SpyHistogram
133143
sessionCost: SpyHistogram
144+
linesTotal: SpyGauge
134145
}
135146
logger: SpyLogger
136147
pluginLog: SpyPluginLog
@@ -152,6 +163,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
152163
const sessionDurationHistogram = makeHistogram()
153164
const sessionTokenGauge = makeHistogram()
154165
const sessionCostGauge = makeHistogram()
166+
const linesTotalGauge = makeGauge()
155167
const logger = makeLogger()
156168
const pluginLog = makePluginLog()
157169
const tracer = makeTracer()
@@ -161,6 +173,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
161173
tokenCounter: token as unknown as Counter,
162174
costCounter: cost as unknown as Counter,
163175
linesCounter: lines as unknown as Counter,
176+
linesTotalGauge: linesTotalGauge as unknown as Gauge,
164177
commitCounter: commit as unknown as Counter,
165178
toolDurationHistogram: toolHistogram as unknown as Histogram,
166179
cacheCounter: cache as unknown as Counter,
@@ -181,6 +194,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
181194
pendingToolSpans: new Map(),
182195
pendingPermissions: new Map(),
183196
sessionTotals: new Map(),
197+
sessionDiffTotals: new Map(),
184198
disabledMetrics: new Set(disabledMetrics),
185199
disabledTraces: new Set(disabledTraces),
186200
tracer: tracer as unknown as Tracer,
@@ -195,7 +209,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
195209
ctx,
196210
counters: { session, token, cost, lines, commit, cache, message, modelUsage, retry, subtask },
197211
histograms: { tool: toolHistogram, sessionDuration: sessionDurationHistogram },
198-
gauges: { sessionToken: sessionTokenGauge, sessionCost: sessionCostGauge },
212+
gauges: { sessionToken: sessionTokenGauge, sessionCost: sessionCostGauge, linesTotal: linesTotalGauge },
199213
logger,
200214
pluginLog,
201215
tracer,

0 commit comments

Comments
 (0)