From 25d1aaf8d24ca992ab69e65dd3069542ed3c83aa Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 12 Oct 2026 22:16:49 +1000 Subject: [PATCH 1/2] Write one bar.perk.available point per Reset Credit A single point for all credits kept only the earliest expiry. Each available credit now gets its own point with its perk.id and expiry, as Telemetry Spec v1 asks when the tool identifies each Perk. --- .../CodexBarCore/OTLPTelemetryEncoder.swift | 19 +++++-- .../CodexBarCoreTests/BarTelemetryTests.swift | 50 ++++++++++++++++--- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/OTLPTelemetryEncoder.swift b/Sources/CodexBarCore/OTLPTelemetryEncoder.swift index 2004001..b21acdc 100644 --- a/Sources/CodexBarCore/OTLPTelemetryEncoder.swift +++ b/Sources/CodexBarCore/OTLPTelemetryEncoder.swift @@ -128,11 +128,20 @@ public enum OTLPTelemetryEncoder { barMetrics.append(Metric(name: "bar.limit.resets_at", unit: "s", gauge: Gauge(dataPoints: resetsAt))) } if let resets = result.rateLimitResetCredits { - let expiry = (resets.credits ?? []).filter { $0.status == "available" }.compactMap(\.expiresAt).min() - barMetrics.append(Metric(name: "bar.perk.available", unit: "{perk}", gauge: Gauge(dataPoints: [Point( - attributes: BarEvents.perkAttributes(id: nil, expiresAt: expiry), timeUnixNano: timestamp, - asDouble: nil, asInt: String(resets.availableCount) - )]))) + // One point per credit so Bar UI sees each expiry. Bar UI sums the points, so any + // credits the list doesn't itemise go in an ID-less point to keep the total right; + // with none at all a single 0 point is how Bar UI sees them run out. + let listed = (resets.credits ?? []).filter { $0.status == "available" } + var perks = listed.map { credit in + Point(attributes: BarEvents.perkAttributes(id: credit.id, expiresAt: credit.expiresAt), timeUnixNano: timestamp, + asDouble: nil, asInt: "1") + } + let unlisted = max(resets.availableCount - listed.count, 0) + if unlisted > 0 || perks.isEmpty { + perks.append(Point(attributes: BarEvents.perkAttributes(id: nil, expiresAt: nil), timeUnixNano: timestamp, + asDouble: nil, asInt: String(unlisted))) + } + barMetrics.append(Metric(name: "bar.perk.available", unit: "{perk}", gauge: Gauge(dataPoints: perks))) } } if let tokens, !tokens.totals.isEmpty { diff --git a/Tests/CodexBarCoreTests/BarTelemetryTests.swift b/Tests/CodexBarCoreTests/BarTelemetryTests.swift index b65ffcf..9f0b603 100644 --- a/Tests/CodexBarCoreTests/BarTelemetryTests.swift +++ b/Tests/CodexBarCoreTests/BarTelemetryTests.swift @@ -253,18 +253,54 @@ final class BarTelemetryTests: XCTestCase { XCTAssertTrue(countDrop.events[1].attributes.contains { $0.key == "perk.quantity" && $0.value.intValue == "2" }) } - func testPerkGaugeCountsAvailableCreditsWithEarliestExpiry() throws { - let usage = try result(#""" + /// The `bar.perk.available` points as (value, attributes by key), in the order they were written. + private func perkPoints(_ json: String) throws -> [(value: String, attributes: [String: String])] { + let usage = try result(json) + let data = try OTLPTelemetryEncoder.metrics(result: usage, planType: nil, observedAt: nil, capturedAt: Date(), serviceVersion: "t") + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let metrics = try XCTUnwrap(((((root["resourceMetrics"] as? [[String: Any]])?.first?["scopeMetrics"]) as? [[String: Any]])? + .flatMap { ($0["metrics"] as? [[String: Any]]) ?? [] })) + let metric = try XCTUnwrap(metrics.first { $0["name"] as? String == "bar.perk.available" }) + let points = try XCTUnwrap((metric["gauge"] as? [String: Any])?["dataPoints"] as? [[String: Any]]) + return points.map { point in + var attributes: [String: String] = [:] + for attribute in (point["attributes"] as? [[String: Any]]) ?? [] { + let value = attribute["value"] as? [String: Any] + attributes[attribute["key"] as? String ?? ""] = (value?["stringValue"] ?? value?["intValue"]) as? String + } + return (point["asInt"] as? String ?? "", attributes) + } + } + + func testPerkGaugeWritesOnePointPerAvailableCredit() throws { + let points = try perkPoints(#""" {"rateLimitResetCredits":{"availableCount":2,"credits":[ {"id":"a","status":"available","resetType":"x","grantedAt":1,"expiresAt":2000}, {"id":"b","status":"available","resetType":"x","grantedAt":1,"expiresAt":1500}, {"id":"c","status":"redeemed","resetType":"x","grantedAt":1,"expiresAt":100}]}} """#) - let data = try OTLPTelemetryEncoder.metrics(result: usage, planType: nil, observedAt: nil, capturedAt: Date(), serviceVersion: "t") - let text = String(decoding: data, as: UTF8.self) - XCTAssertTrue(text.contains(#""name":"bar.perk.available""#)) - XCTAssertTrue(text.contains(#"{"key":"perk.expires_at","value":{"intValue":"1500"}}"#)) - XCTAssertTrue(text.contains(#"{"key":"perk.kind","value":{"stringValue":"reset_credit"}}"#)) + XCTAssertEqual(points.map(\.value), ["1", "1"]) + XCTAssertEqual(points.map { $0.attributes["perk.id"] }, ["a", "b"]) + XCTAssertEqual(points.map { $0.attributes["perk.expires_at"] }, ["2000", "1500"]) + XCTAssertEqual(points.map { $0.attributes["perk.kind"] }, ["reset_credit", "reset_credit"]) + } + + func testPerkGaugeKeepsTheTotalWhenTheListIsShorterThanTheCount() throws { + let points = try perkPoints(#""" + {"rateLimitResetCredits":{"availableCount":3,"credits":[ + {"id":"a","status":"available","resetType":"x","grantedAt":1,"expiresAt":2000}]}} + """#) + XCTAssertEqual(points.map(\.value), ["1", "2"]) + XCTAssertEqual(points[0].attributes["perk.id"], "a") + XCTAssertNil(points[1].attributes["perk.id"]) + XCTAssertNil(points[1].attributes["perk.expires_at"]) + } + + func testPerkGaugeStillWritesZeroWhenThereAreNoCredits() throws { + let points = try perkPoints(#"{"rateLimitResetCredits":{"availableCount":0,"credits":[]}}"#) + XCTAssertEqual(points.map(\.value), ["0"]) + XCTAssertNil(points[0].attributes["perk.id"]) + XCTAssertEqual(points[0].attributes["perk.kind"], "reset_credit") } func testTelemetryURLAcceptsAnySubsetAndRejectsTheWholeRequestOnABadValue() throws { From 521b35a6291b6966566ac6d6f59e7299ae68d6a9 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 12 Oct 2026 22:21:39 +1000 Subject: [PATCH 2/2] Never name more credits than Codex's available count If the list holds more available rows than availableCount, only that many are written, soonest expiry first, so the points never add up past the count. The telemetry doc now describes the per-credit gauge. --- .../CodexBarCore/OTLPTelemetryEncoder.swift | 16 +++++++++++++++- .../CodexBarCoreTests/BarTelemetryTests.swift | 19 +++++++++++++++++++ docs/telemetry.md | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/OTLPTelemetryEncoder.swift b/Sources/CodexBarCore/OTLPTelemetryEncoder.swift index b21acdc..4971752 100644 --- a/Sources/CodexBarCore/OTLPTelemetryEncoder.swift +++ b/Sources/CodexBarCore/OTLPTelemetryEncoder.swift @@ -131,7 +131,21 @@ public enum OTLPTelemetryEncoder { // One point per credit so Bar UI sees each expiry. Bar UI sums the points, so any // credits the list doesn't itemise go in an ID-less point to keep the total right; // with none at all a single 0 point is how Bar UI sees them run out. - let listed = (resets.credits ?? []).filter { $0.status == "available" } + // `availableCount` is the authority: when the list holds more available rows than it, + // only that many are named, soonest expiry first, so the points never sum past it. + let available: [ResetCredit] = (resets.credits ?? []).filter { $0.status == "available" } + let authoritative = max(resets.availableCount, 0) + let listed: [ResetCredit] + if available.count <= authoritative { + listed = available + } else { + let soonestFirst: [ResetCredit] = available.sorted { (lhs: ResetCredit, rhs: ResetCredit) -> Bool in + let left: Double = lhs.expiresAt ?? .infinity + let right: Double = rhs.expiresAt ?? .infinity + return left < right + } + listed = Array(soonestFirst.prefix(authoritative)) + } var perks = listed.map { credit in Point(attributes: BarEvents.perkAttributes(id: credit.id, expiresAt: credit.expiresAt), timeUnixNano: timestamp, asDouble: nil, asInt: "1") diff --git a/Tests/CodexBarCoreTests/BarTelemetryTests.swift b/Tests/CodexBarCoreTests/BarTelemetryTests.swift index 9f0b603..69666eb 100644 --- a/Tests/CodexBarCoreTests/BarTelemetryTests.swift +++ b/Tests/CodexBarCoreTests/BarTelemetryTests.swift @@ -296,6 +296,25 @@ final class BarTelemetryTests: XCTestCase { XCTAssertNil(points[1].attributes["perk.expires_at"]) } + func testPerkGaugeNeverNamesMoreCreditsThanTheCount() throws { + let points = try perkPoints(#""" + {"rateLimitResetCredits":{"availableCount":1,"credits":[ + {"id":"later","status":"available","resetType":"x","grantedAt":1,"expiresAt":3000}, + {"id":"sooner","status":"available","resetType":"x","grantedAt":1,"expiresAt":2000}]}} + """#) + XCTAssertEqual(points.map(\.value), ["1"]) + XCTAssertEqual(points[0].attributes["perk.id"], "sooner") + } + + func testPerkGaugeWritesZeroWhenTheCountIsZeroEvenIfCreditsAreListed() throws { + let points = try perkPoints(#""" + {"rateLimitResetCredits":{"availableCount":0,"credits":[ + {"id":"a","status":"available","resetType":"x","grantedAt":1,"expiresAt":2000}]}} + """#) + XCTAssertEqual(points.map(\.value), ["0"]) + XCTAssertNil(points[0].attributes["perk.id"]) + } + func testPerkGaugeStillWritesZeroWhenThereAreNoCredits() throws { let points = try perkPoints(#"{"rateLimitResetCredits":{"availableCount":0,"credits":[]}}"#) XCTAssertEqual(points.map(\.value), ["0"]) diff --git a/docs/telemetry.md b/docs/telemetry.md index cdacf79..1b18707 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -50,7 +50,7 @@ The count's position and totals live in `~/Library/Application Support/CodexBar/ Codex reset credits are `perk.kind=reset_credit`. -- The `bar.perk.available` gauge holds Codex's available count. `perk.expires_at` is the earliest expiry among the available credits. +- The `bar.perk.available` gauge has one point per available credit, each with value 1, the credit's `perk.id` and its `perk.expires_at`. The points add up to Codex's available count. If the count is higher than the credits the list names, the rest go in one point with no `perk.id`. If the list names more available credits than the count, only that many are written, soonest expiry first. With no credits, the gauge is a single 0 point. - A credit that Codex reports as redeemed or redeeming produces `bar.perk.gone` with `perk.gone.reason=used`. - When the count drops for any other reason, CodexBar reports the missing credit as gone. The reason is `expired` if its expiry has passed, and left out otherwise. - A credit missing from a list while the count holds is not treated as gone, because Codex can return a capped list.