Skip to content
Open
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
8 changes: 5 additions & 3 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ We collect the following categories of events:
| `environment_scan_completed` | A `project_scan` finished during onboarding — `has_dbt`, `has_warehouse`, `is_repo`, `connections_found`, and a bounded list of short `degraded` detection keys. No paths, hostnames, or connection details. Emitted only inside an onboarding session, and only once per session, so scans from `/discover` or a model-initiated call are excluded. |
| `sample_setup_completed` | The sample dbt project was materialised. `success`, `models`, `tables`, and `reused` — the tool is deliberately re-callable, so this is per invocation. The target path is never sent. |
| `activation_menu_shown` | The activation menu was (very likely) rendered. `variant` is `warehouse` or `no_data`. **Derived** — see the note below. |
| `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. In onboarding, completion is reported only for the job that was actually selected, so the two form a coherent pair. `first_job_completed` is also sent once per workspace for each machine and Altimate instance, for the first turn in a linked project that had a tool call that didn't fail and finished. **Derived** — see the note below. |
| `first_prompt_sent` | The user's first typed message in an onboarding session, and the first typed message in a linked project, once per workspace for each machine and Altimate instance. Slash commands are excluded, so the hidden `/onboard-connect` submission does not count. |
| `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. In onboarding, completion is reported only for the job that was actually selected, so the two form a coherent pair. `first_job_completed` is also sent once per workspace for each machine and Altimate instance, for the first turn in a linked project, or in a session pinned to a workspace by the IDE extension, that had a tool call that didn't fail and ended `completed`. `origin` says which: `onboarding` or `workspace`. **Derived** — see the note below. |
| `first_prompt_sent` | The user's first typed message in an onboarding session, and the first typed message in a linked project or a session pinned to a workspace, once per workspace for each machine and Altimate instance. A slash command sent as a command, as the TUI and `run` send it, is excluded, so the hidden `/onboard-connect` submission does not count. The VS Code chat sends a command as its expanded text, so there a command counts. A subagent's prompt never counts. `origin` says which rule sent it: `onboarding` for an onboarding session's first typed prompt, bound or not, and `workspace` for the workspace's row. So a later prompt in an onboarding session, after its project was linked, is a `workspace` row. |
| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). |
| `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. |
| `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. |
Expand Down Expand Up @@ -138,7 +138,9 @@ They are therefore inferred from the closest deterministic signals — the menu
- In onboarding, the "something else" branch has no tool signature at all and is never counted.
- In onboarding, `first_job_completed` only fires for jobs with a real completion signal. Skill-driven jobs (downstream impact, SQL review, cost) load an instruction bundle and then do their work through other tools, so their completion is not observable and they are absent from this event rather than wrongly counted in it.

In a linked project, `first_job_completed` means something broader: a turn with at least one tool call that didn't fail, which finished without an error or a cancel. Nothing says which job that was, so it reports `job` as `something_else`. What has been sent is remembered as one empty file per Altimate instance, workspace and event in the CLI's state directory, outside the project. An onboarding session still sends its own `first_job_completed` for the job it selected, even if the workspace's row was sent before it.
In a linked project, or a session pinned to a workspace by the IDE extension, `first_job_completed` means something broader: a turn with at least one tool call that didn't fail, which ended `completed`, without an error or a cancel. Nothing says which job that was, so it reports `job` as `something_else`. A subagent's turn doesn't count on its own; the parent's turn counts it through its `task` call. Each row is claimed before it is sent, as one empty file per Altimate instance, workspace and event in the CLI's state directory, outside the project, so the file records a claim, not a confirmed delivery. An onboarding session still sends its own `first_job_completed` for the job it selected, even if the workspace's row was sent before it.

Both rows carry `origin`: `onboarding` when the onboarding rule sent the row, `workspace` when the workspace rule did. Rows from clients older than this field have no `origin`; they are onboarding rows.

## Delivery & Reliability

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise<Ho
// from the trigger site: a tool returning after its turn was cancelled and replaced still
// names its own turn's workspace, not the replacement's.
void OnboardingTelemetry.emit(
{ type: "first_job_completed", job, workspace_id: input.workspaceId },
{ type: "first_job_completed", job, workspace_id: input.workspaceId, origin: "onboarding" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: origin: "onboarding" identifies the turn's workspace, but the suppression claim below still uses the ambient credential instead of that turn's resolved scope. Pass the resolved binding scope through the tool hook before claiming, or an account switch can mis-key the mark and produce a duplicate workspace row.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/plugin/onboarding-telemetry.ts, line 269:

<comment>`origin: "onboarding"` identifies the turn's workspace, but the suppression claim below still uses the ambient credential instead of that turn's resolved scope. Pass the resolved binding scope through the tool hook before claiming, or an account switch can mis-key the mark and produce a duplicate workspace row.</comment>

<file context>
@@ -266,7 +266,7 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise<Ho
         // names its own turn's workspace, not the replacement's.
         void OnboardingTelemetry.emit(
-          { type: "first_job_completed", job, workspace_id: input.workspaceId },
+          { type: "first_job_completed", job, workspace_id: input.workspaceId, origin: "onboarding" },
           input.sessionID,
         )
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same point as the first-events.ts thread: R8 on the review log. This row's mark is claimed with the credential read at claim time, as before this PR. Carrying the turn's scope into the hook needs it on the tool context, which is out of scope here. The effect is at most one duplicate workspace row after a credential switch inside an onboarding turn.

input.sessionID,
)
// Marked so the end of this turn, or a later session, doesn't send the workspace's row as well.
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,9 @@ export namespace Telemetry {
type: "first_job_completed"
/** altimate_change — the turn's workspace, named by the emitter */
workspace_id?: number
/** altimate_change — which rule sent the row: an onboarding session's selected job, or the
* workspace's first job on this machine. Rows from older clients have none and are onboarding rows. */
origin: "onboarding" | "workspace"
timestamp: number
session_id: string
job: "sample_duck_db" | "breaks_downstream" | "sql_review" | "cost" | "something_else"
Expand All @@ -1554,6 +1557,9 @@ export namespace Telemetry {
type: "first_prompt_sent"
/** altimate_change — the turn's workspace, named by the emitter */
workspace_id?: number
/** altimate_change — which rule sent the row: `onboarding` for an onboarding session's first typed
* prompt, bound or not, else `workspace`. Rows from older clients have none and are onboarding rows. */
origin: "onboarding" | "workspace"
timestamp: number
session_id: string
}
Expand Down
46 changes: 34 additions & 12 deletions packages/opencode/src/altimate/workspace/first-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@ import { Global } from "@/global"
import { Log } from "@/altimate/util/log"
import { Telemetry } from "@/altimate/telemetry"
import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding"
import { currentScope } from "./state"
import { currentScope, type AccountKey } from "./state"

const log = Log.create({ service: "workspace-first-events" })

export type FirstEvent = "first_prompt_sent" | "first_job_completed"
type Scope = Pick<AccountKey, "tenant" | "apiUrl">

/** What this process already knows is sent, so a bound turn skips the filesystem after the first. */
const sent = new Set<string>()
/** Set once a mark couldn't be written, so a state directory that stays unwritable logs once, not
* every turn. The next turn still tries again. */
let warnedUnwritable = false

/**
* True the one time this machine should send `event` for this workspace.
Expand All @@ -38,9 +42,11 @@ const sent = new Set<string>()
* mark that can't be written claims nothing either, and the next turn tries again. The claim comes
* before the send, so a process killed between the two loses the row rather than duplicating it.
*/
export async function claim(event: FirstEvent, workspaceId: number): Promise<boolean> {
export async function claim(event: FirstEvent, workspaceId: number, scope?: Scope): Promise<boolean> {
if (!Telemetry.isEnabled()) return false
const scope = await currentScope().catch(() => null)
// The credential the turn's binding was resolved under, when the caller has it: the workspace id
// belongs to that tenant, and the turn doesn't read the credentials again.
scope ??= (await currentScope().catch(() => null)) ?? undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: claim falls back to ambient credentials when no scope is passed, but the onboarding hook supplies only workspaceId. A credential switch during the turn can mark tenant B for tenant A, after which jobCompleted emits a duplicate or credits the wrong tenant; pass the turn's resolved scope to this claim.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/workspace/first-events.ts, line 49:

<comment>`claim` falls back to ambient credentials when no scope is passed, but the onboarding hook supplies only `workspaceId`. A credential switch during the turn can mark tenant B for tenant A, after which `jobCompleted` emits a duplicate or credits the wrong tenant; pass the turn's resolved scope to this claim.</comment>

<file context>
@@ -38,9 +42,11 @@ const sent = new Set<string>()
-  const scope = await currentScope().catch(() => null)
+  // The credential the turn's binding was resolved under, when the caller has it: the workspace id
+  // belongs to that tenant, and the turn doesn't read the credentials again.
+  scope ??= (await currentScope().catch(() => null)) ?? undefined
   if (!scope) return false
   const id = createHash("sha256")
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this in this PR. It's R8 on the review log. The onboarding hook claims its mark with the credential read at claim time, as it did before this PR; C4 covers only a passed scope. Passing the turn's scope here means adding it to the tool context the plugin hook receives, which is wider than this follow-up. The effect needs a credential switch inside an onboarding turn, and it is at most one duplicate workspace row.

if (!scope) return false
const id = createHash("sha256")
.update(JSON.stringify([scope.tenant, scope.apiUrl, workspaceId, event]))
Expand All @@ -49,34 +55,49 @@ export async function claim(event: FirstEvent, workspaceId: number): Promise<boo
if (sent.has(id)) return false
const dir = path.join(Global.Path.state, "altimate-workspace-first-events")
// Apart from the write: only the exclusive create may answer EEXIST with "already sent". A failure
// here shows up on the write below.
await mkdir(dir, { recursive: true }).catch(() => undefined)
// here shows up on the write below, and is logged with it, since it is usually the cause.
const dirError = await mkdir(dir, { recursive: true }).then(
() => undefined,
(err: unknown) => String(err),
)
try {
// `wx` makes the create exclusive on every platform. `mode` has no effect on Windows, where the
// file takes the state directory's permissions.
await writeFile(path.join(dir, id), "", { flag: "wx", mode: 0o600 })
sent.add(id)
return true
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") sent.add(id)
else log.warn("could not record a workspace first event", { event, err: String(err) })
else if (!warnedUnwritable) {
warnedUnwritable = true
log.warn("could not record a workspace first event; later failures in this process are not logged", {
event,
err: String(err),
...(dirError && { dirError }),
})
}
return false
}
}

/** A typed prompt: the onboarding row and the workspace row are one row when both apply. */
export async function promptSent(sessionID: string, workspaceId: number | undefined): Promise<void> {
export async function promptSent(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise<void> {
const onboarding = OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID)
const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId))
const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId, scope))
if (onboarding || inWorkspace) {
await OnboardingTelemetry.emit({ type: "first_prompt_sent", workspace_id: workspaceId }, sessionID)
await OnboardingTelemetry.emit(
{ type: "first_prompt_sent", workspace_id: workspaceId, origin: onboarding ? "onboarding" : "workspace" },
sessionID,
)
}
}

/** A turn in a workspace that ended `completed` with at least one tool call that didn't fail. */
export async function jobCompleted(sessionID: string, workspaceId: number | undefined): Promise<void> {
export async function jobCompleted(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise<void> {
if (workspaceId === undefined) return
if (await claim("first_job_completed", workspaceId)) {
if (await claim("first_job_completed", workspaceId, scope)) {
await OnboardingTelemetry.emit(
{ type: "first_job_completed", job: "something_else", workspace_id: workspaceId },
{ type: "first_job_completed", job: "something_else", workspace_id: workspaceId, origin: "workspace" },
sessionID,
)
}
Expand All @@ -85,4 +106,5 @@ export async function jobCompleted(sessionID: string, workspaceId: number | unde
/** Test seam: drops this process's memory of the marks, as a new process would start. */
export function resetForTest() {
sent.clear()
warnedUnwritable = false
}
Loading
Loading