diff --git a/Sources/CodexBarCore/OTLPTelemetryEncoder.swift b/Sources/CodexBarCore/OTLPTelemetryEncoder.swift index 2004001..4971752 100644 --- a/Sources/CodexBarCore/OTLPTelemetryEncoder.swift +++ b/Sources/CodexBarCore/OTLPTelemetryEncoder.swift @@ -128,11 +128,34 @@ 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. + // `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") + } + 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..69666eb 100644 --- a/Tests/CodexBarCoreTests/BarTelemetryTests.swift +++ b/Tests/CodexBarCoreTests/BarTelemetryTests.swift @@ -253,18 +253,73 @@ 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 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"]) + XCTAssertNil(points[0].attributes["perk.id"]) + XCTAssertEqual(points[0].attributes["perk.kind"], "reset_credit") } func testTelemetryURLAcceptsAnySubsetAndRejectsTheWholeRequestOnABadValue() throws { 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.