Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src-tauri/src/commands/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const TELEMETRY_BOOTSTRAP_PATH: &str = "/v1/bootstrap";
// lost because the renderer's `BatchLogRecordProcessor` drops it.
// `/v1/bootstrap` does not read it.
const TELEMETRY_SCHEMA_VERSION_HEADER: &str = "x-berd-schema-version";
const TELEMETRY_SCHEMA_VERSION: &str = "berd-otlp-logs-v1";
const TELEMETRY_SCHEMA_VERSION: &str = "berd-otlp-logs-v2";

// Telemetry-gateway host allowlist. The renderer's OTLP endpoint is
// build-injected from VITE_OTLP_LOGS_ENDPOINT (see vite.config.ts) and must
Expand Down Expand Up @@ -1199,7 +1199,7 @@ mod tests {
// comma-joined, which it rejects like a missing one.
assert_eq!(
request.header_values(TELEMETRY_SCHEMA_VERSION_HEADER),
vec!["berd-otlp-logs-v1"]
vec!["berd-otlp-logs-v2"]
);
assert_eq!(request.header("content-type"), Some("application/json"));
// The gateway hands the raw request bytes to its JSON parser, so a
Expand Down
7 changes: 7 additions & 0 deletions src/features/voice-conversation/lib/voiceTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { track } from "@/shared/telemetry/client";
import { berdVoiceConversationStarted } from "@/shared/telemetry/events";

/** A native voice conversation completed startup successfully. */
export function trackVoiceConversationStarted(): void {
track(berdVoiceConversationStarted());
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
start: vi.fn(),
stop: vi.fn(),
stopForReplacement: vi.fn(),
trackStarted: vi.fn(),
}));

vi.mock("../api/voiceConversation", () => ({
Expand All @@ -35,6 +36,10 @@ vi.mock("../api/voiceConversation", () => ({
stopVoiceConversationForReplacement: mocks.stopForReplacement,
}));

vi.mock("../lib/voiceTelemetry", () => ({
trackVoiceConversationStarted: mocks.trackStarted,
}));

function status(
lifecycle: VoiceConversationStatus["lifecycle"],
revision: number,
Expand Down Expand Up @@ -73,6 +78,7 @@ describe("voice conversation store lifecycle ordering", () => {
mocks.start.mockReset();
mocks.stop.mockReset();
mocks.stopForReplacement.mockReset();
mocks.trackStarted.mockReset();
mocks.listen.mockReset().mockImplementation(async (callback) => {
emit = callback;
return vi.fn();
Expand Down Expand Up @@ -483,6 +489,18 @@ describe("voice conversation store lifecycle ordering", () => {
await expect(
useVoiceConversationStore.getState().start("session-1"),
).resolves.toMatchObject({ lifecycle: "running", sessionId: "session-1" });
expect(mocks.trackStarted).toHaveBeenCalledOnce();
});

it("does not track voice usage when native startup fails", async () => {
const store = await loadStore();
mocks.start.mockRejectedValue(new Error("microphone unavailable"));

await expect(store.getState().start("session-1")).rejects.toThrow(
"microphone unavailable",
);

expect(mocks.trackStarted).not.toHaveBeenCalled();
});

it("waits for an existing start before granting an archive lease", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type VoiceConversationStatus,
} from "../api/voiceConversation";
import type { VoiceInputBackend } from "../lib/voiceInputPreference";
import { trackVoiceConversationStarted } from "../lib/voiceTelemetry";

export type VoiceConversationUiState =
| "off"
Expand Down Expand Up @@ -644,6 +645,7 @@ export const useVoiceConversationStore = create<VoiceConversationStore>(
inputBackend,
foregroundGeneration,
);
trackVoiceConversationStarted();
set((state) =>
shouldApplyResponseRevision(state.status, status.revision) ||
(status.revision === state.status.revision &&
Expand Down
4 changes: 2 additions & 2 deletions src/shared/telemetry/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe("telemetry", () => {
app_version: expect.any(String),
environment: "production",
});
// `user_id` is gone from the wire contract entirely — `berd-otlp-logs-v1`
// `user_id` is gone from the wire contract entirely — `berd-otlp-logs-v2`
// rejects its presence — so nothing may reintroduce it.
expect(record.attributes).not.toHaveProperty("user_id");
// Emitted immediately (not backdated), so no explicit timestamp.
Expand Down Expand Up @@ -378,7 +378,7 @@ describe("telemetry", () => {
await new Promise((resolve) => setTimeout(resolve, 0));

// Pinned as literals, not as the module's own constants: the gateway's
// `berd-otlp-logs-v1` schema accepts exactly these values — both renamed
// `berd-otlp-logs-v2` schema accepts exactly these values — both renamed
// from `goose-internal` before any client shipped — so a revert is a
// terminal 400 on every upload, and the dropped batch is never retried.
expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({
Expand Down
5 changes: 2 additions & 3 deletions src/shared/telemetry/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,8 @@ const appVersion = import.meta.env.VITE_APP_VERSION ?? "0.0.0";
const TELEMETRY_DEBUG_STORAGE_KEY = "berd.telemetry.debug";

// OTel instrumentation scope for every emitted log record. The gateway's
// `berd-otlp-logs-v1` schema pins this exact literal (renamed from
// `goose-internal.telemetry` before any client shipped), so it moves in
// lockstep with the gateway schema, not with local naming.
// `berd-otlp-logs-v2` schema pins this exact literal, so it moves in lockstep
// with the gateway schema, not with local naming.
const TELEMETRY_SCOPE_NAME = "berd.telemetry";
// `deployment.environment` is an incubating semantic convention; inline the key
// to avoid importing the large `/incubating` module for a single constant.
Expand Down
20 changes: 20 additions & 0 deletions src/shared/telemetry/events/berd_voice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Vendored-style typed telemetry event factory. Berd's event modules are
// maintained locally; keep this event name and parameter shape aligned with
// the versioned allowlist in squareup/berd-monitoring.

import type { Event } from "./event";

/**
* Berd Voice · Conversation · Started
*
* Counts a voice conversation only after native voice startup succeeds. The
* event intentionally has no attributes: the anonymous installation resource
* identity is enough to measure adoption without collecting session or voice
* configuration details.
*/
export function berdVoiceConversationStarted(): Event {
return {
name: "berd_voice_conversation_started",
parameters: {},
};
}
10 changes: 10 additions & 0 deletions src/shared/telemetry/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
berdProjectDeleteCompleted,
berdProjectEditCompleted,
} from "./berd_project";
import { berdVoiceConversationStarted } from "./berd_voice";

// The vendored set is a curated subset of the schema repo (see ./index.ts): the
// port excluded every *Initiated* variant, so a factory for one has no call
Expand All @@ -36,6 +37,15 @@ describe("vendored event surface", () => {
});
});

describe("voice events", () => {
it("keeps successful voice startup as a privacy-safe bare counter", () => {
expect(berdVoiceConversationStarted()).toEqual({
name: "berd_voice_conversation_started",
parameters: {},
});
});
});

// The entity-id attributes left the wire the same way `user_id` did: the
// gateway's strict schema models no `agent_id` (the persona's on-disk path —
// the agent's name plus the OS username), no `project_id` (a slug of the
Expand Down
1 change: 1 addition & 0 deletions src/shared/telemetry/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export * from "./berd_app";
export * from "./berd_chat";
export * from "./berd_home";
export * from "./berd_project";
export * from "./berd_voice";
4 changes: 2 additions & 2 deletions src/shared/telemetry/exporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe("TauriOtlpLogExporter", () => {
});

/**
* The ingestion gateway validates every upload against the `berd-otlp-logs-v1`
* The ingestion gateway validates every upload against the `berd-otlp-logs-v2`
* body schema, which is strict/closed at every level: exactly one
* `resourceLogs` entry, exactly one `scopeLogs` entry, an exact key set on
* every object, a closed set of resource attributes, and only string/bool
Expand All @@ -246,7 +246,7 @@ describe("TauriOtlpLogExporter", () => {
* `version` appearing, a `severityNumber`/`flags` key on the record, or
* `timeUnixNano` switching from a JSON string to a number.
*/
describe("berd-otlp-logs-v1 body contract", () => {
describe("berd-otlp-logs-v2 body contract", () => {
// The gateway's whole accepted resource-attribute set. `service.name` must be
// the literal `berd` (it, and the scope, were renamed from `goose-internal`
// before any client shipped), and `distribution.channel` must carry one of
Expand Down
Loading