Skip to content

Commit 4a5594d

Browse files
committed
Merge origin/main (telemetry error events, sandbox-conflict warning) into the QA-fix branch
Main's #90 fixed two of the same QA findings differently: adopt its --sandbox/--env conflict warning (--env wins, surfaced as {"warning": …} in JSON mode) over this branch's hard usage error, and keep this branch's fuller speak sandbox-hint suggestion which subsumes main's. https://claude.ai/code/session_01LkB3yQbPDG7Ex4mNL3Wtb6
2 parents 12b7e1a + 63bc287 commit 4a5594d

10 files changed

Lines changed: 264 additions & 57 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `ag
180180
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
181181
- **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
182182
- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`.
183-
- **`telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS — never args, paths, or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command.
183+
- **`telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS, and on failure the error message capped at 500 chars — never args or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command.
184184
- **`commands/setup.py`**`assembly setup install/status/remove` wires a coding agent up to AssemblyAI by installing three artifacts: the `assemblyai-docs` docs MCP (via `claude mcp add`), the AssemblyAI skill (via `npx skills add`), and the bundled `aai-cli` skill (copied out of the wheel, no network). Missing `claude`/`npx` is reported and skipped, not an error. The presence probes (docs MCP registered, skills on disk) live in `aai_cli/coding_agent.py` so `assembly doctor`'s coding-agent check can share them — command modules are import-linter-independent, so neither command may import the other.
185185

186186
## Conventions

aai_cli/commands/speak.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ def speak(
217217
--out. Speaker-labeled input (from 'assembly transcribe
218218
--speaker-labels') is detected automatically: the labels are stripped
219219
and each speaker gets a different voice. This feature only exists in
220-
the sandbox today — run it as 'assembly --sandbox speak'.
220+
the sandbox today — run it as 'assembly --sandbox speak' (--sandbox
221+
goes before the subcommand).
221222
"""
222223

223224
def body(state: AppState, json_mode: bool) -> None:

aai_cli/main.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,17 @@ def _command_line_requests_json(raw_args: list[str]) -> bool:
297297
return False
298298

299299

300+
def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None:
301+
"""A warning when ``--sandbox`` and a contradictory ``--env`` are both passed.
302+
303+
Credentials are environment-bound, so the conflict must not be resolved silently:
304+
``--env`` wins, and the warning names the loser so the user can drop a flag.
305+
"""
306+
if sandbox and env is not None and env != "sandbox000":
307+
return f"--sandbox ignored: --env {env} takes precedence."
308+
return None
309+
310+
300311
def _offer_or_help(ctx: typer.Context, state: AppState) -> None:
301312
"""No subcommand given: offer guided setup to a credential-less, interactive user;
302313
otherwise print help. Never prompts in a non-interactive session, and never on
@@ -353,21 +364,12 @@ def main(
353364
),
354365
) -> None:
355366
# The command's own --json flag isn't parsed yet, so sniff the pending command line:
356-
# a root-callback failure (a bad --env, a --sandbox/--env conflict) still emits the
357-
# JSON error shape when the invocation opted into JSON, and renders human text
358-
# on stderr otherwise.
367+
# a root-callback failure (e.g. bad --env) still emits the JSON error shape when the
368+
# invocation opted into JSON, and renders human text on stderr otherwise.
359369
raw_args: list[str] = ctx.meta.get(_RAW_ARGS_META_KEY, [])
360370
json_mode = output.resolve_json(explicit=_command_line_requests_json(raw_args))
361-
if sandbox and env is not None and env != "sandbox000":
362-
# Resolving the disagreement silently (to either side) would send credentials
363-
# to an environment the user didn't expect, so refuse the contradiction.
364-
conflict = UsageError(
365-
f"--sandbox conflicts with --env {env}: --sandbox is shorthand for --env sandbox000.",
366-
suggestion="Drop --sandbox, or pass --env sandbox000.",
367-
)
368-
output.emit_error(conflict, json_mode=json_mode)
369-
raise typer.Exit(code=conflict.exit_code)
370-
if sandbox:
371+
conflict_warning = _sandbox_conflict_warning(sandbox, env)
372+
if sandbox and env is None:
371373
env = "sandbox000"
372374
state = AppState(profile=profile, env=env, quiet=quiet)
373375
ctx.obj = state
@@ -376,11 +378,11 @@ def main(
376378
except CLIError as err:
377379
output.emit_error(err, json_mode=json_mode)
378380
raise typer.Exit(code=err.exit_code) from None
379-
warning = env_override_warning(state)
380-
if warning and not quiet:
381-
# Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets a
382-
# machine-readable hint instead of an unexplained downstream auth failure.
383-
output.emit_warning(warning, json_mode=json_mode)
381+
for warning in (conflict_warning, env_override_warning(state)):
382+
if warning and not quiet:
383+
# Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets
384+
# a machine-readable hint instead of an unexplained downstream auth failure.
385+
output.emit_warning(warning, json_mode=json_mode)
384386
if ctx.invoked_subcommand is None:
385387
_offer_or_help(ctx, state)
386388

aai_cli/telemetry.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Anonymous usage telemetry, modeled on the Supabase CLI's design.
22
33
One allow-listed event per command run (command path, outcome, duration — never
4-
arguments, file paths, ids, or account data) is shipped to the Datadog logs
4+
arguments, ids, or account data; a failure also carries the error message,
5+
capped at 500 chars) is shipped to the Datadog logs
56
intake using a write-only *client* token (``pub…``), the credential class
67
Datadog designs to be embedded in client apps. ``SHIPPED_CLIENT_TOKEN`` carries
78
it (it is public by design — never put an API key there);
@@ -47,6 +48,10 @@
4748

4849
_SEND_TIMEOUT_SECONDS = 5.0
4950

51+
# Cap on the error message shipped with a failure event: enough for any CLI
52+
# error line, while bounding the payload if an upstream message embeds a body.
53+
_ERROR_MESSAGE_MAX_CHARS = 500
54+
5055

5156
def client_token() -> str:
5257
"""The write-only intake token: env override first, then the shipped one."""
@@ -131,7 +136,12 @@ def _maybe_emit_first_run_notice() -> None:
131136

132137

133138
def build_event(
134-
command: str, *, outcome: str, exit_code: int, duration_ms: int
139+
command: str,
140+
*,
141+
outcome: str,
142+
exit_code: int,
143+
duration_ms: int,
144+
error_message: str | None = None,
135145
) -> dict[str, object]:
136146
"""One invocation event, shaped for the Datadog logs intake.
137147
@@ -141,10 +151,11 @@ def build_event(
141151
hostname ever rides along.
142152
143153
A failure additionally sets ``status: error`` and the reserved
144-
``error.kind`` so the event feeds Datadog **Error Tracking** (issue
145-
grouping), not just log search. ``error.kind`` reuses the anonymous
146-
``outcome`` (the ``CLIError.error_type``) — the error *message* and stack
147-
trace are deliberately omitted, so no free text or PII ever rides along.
154+
``error.kind``/``error.message`` so the event feeds Datadog **Error
155+
Tracking** (issue grouping), not just log search. ``error.kind`` reuses the
156+
anonymous ``outcome`` (the ``CLIError.error_type``); ``error.message`` is
157+
the one-line message the user saw (capped at ``_ERROR_MESSAGE_MAX_CHARS``).
158+
Stack traces are still deliberately omitted.
148159
"""
149160
succeeded = outcome == "success"
150161
event: dict[str, object] = {
@@ -164,7 +175,10 @@ def build_event(
164175
"device_id": config.get_device_id(),
165176
}
166177
if not succeeded:
167-
event["error"] = {"kind": outcome}
178+
error: dict[str, object] = {"kind": outcome}
179+
if error_message:
180+
error["message"] = error_message[:_ERROR_MESSAGE_MAX_CHARS]
181+
event["error"] = error
168182
return event
169183

170184

@@ -210,11 +224,24 @@ def flush_payload(raw: str) -> None:
210224
)
211225

212226

213-
def _safe_dispatch(command: str, started: float, *, outcome: str, exit_code: int) -> None:
227+
def _safe_dispatch(
228+
command: str,
229+
started: float,
230+
*,
231+
outcome: str,
232+
exit_code: int,
233+
error_message: str | None = None,
234+
) -> None:
214235
duration_ms = int((time.monotonic() - started) * 1000)
215236
try:
216237
dispatch(
217-
build_event(command, outcome=outcome, exit_code=exit_code, duration_ms=duration_ms)
238+
build_event(
239+
command,
240+
outcome=outcome,
241+
exit_code=exit_code,
242+
duration_ms=duration_ms,
243+
error_message=error_message,
244+
)
218245
)
219246
except (OSError, CLIError):
220247
# Best-effort by contract: a config/spawn failure while *recording* a command
@@ -239,14 +266,22 @@ def track(command: str) -> Generator[None]:
239266
try:
240267
yield
241268
except CLIError as err:
242-
_safe_dispatch(command, started, outcome=err.error_type, exit_code=err.exit_code)
269+
_safe_dispatch(
270+
command,
271+
started,
272+
outcome=err.error_type,
273+
exit_code=err.exit_code,
274+
error_message=err.message,
275+
)
243276
raise
244277
except typer.Exit as exc:
245278
code = exc.exit_code
246279
outcome = "success" if code == 0 else "error"
247280
_safe_dispatch(command, started, outcome=outcome, exit_code=code)
248281
raise
249-
except BaseException:
250-
_safe_dispatch(command, started, outcome="internal_error", exit_code=1)
282+
except BaseException as exc:
283+
_safe_dispatch(
284+
command, started, outcome="internal_error", exit_code=1, error_message=str(exc)
285+
)
251286
raise
252287
_safe_dispatch(command, started, outcome="success", exit_code=0)

aai_cli/transcribe_batch.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ def expand_sources(source: str | None, *, from_stdin: bool, sample: bool) -> lis
7373
"""
7474
if from_stdin:
7575
return _stdin_sources(source, sample=sample)
76-
if source is None or sample or source == "-" or source.startswith(_URL_PREFIXES):
76+
# `not source` (rather than `is None`) also catches the empty string — e.g. an
77+
# unset shell variable in `assembly transcribe "$FILE"`. `Path("")` is `Path(".")`,
78+
# so it would otherwise fall into the directory branch and batch-transcribe the
79+
# whole working directory; instead it stays single-source and fails validation.
80+
if not source or sample or source == "-" or source.startswith(_URL_PREFIXES):
7781
return None
7882
path = Path(source)
7983
if path.is_dir():

docs/datadog/cli-usage-dashboard.json

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"definition": {
1111
"type": "note",
12-
"content": "# AssemblyAI CLI — Usage & Reliability\nBuilt on anonymous usage telemetry (`source:aai-cli`). **Each log line = one command run.** Count tiles work out of the box; the grouped tiles (top commands, outcomes, percentiles, distributions) need facets on `@command`, `@outcome`, `@device_id`, `@duration_ms` (measure), `@os`, `@cli_version`, `@python_version`, `@exit_code`, `@ci`. Failures ship `status:error` + the reserved `@error.kind` (= the anonymous error type, **no message or stack trace**), so they also feed **Error Tracking** — the error tiles below filter on `status:error`. Events take ~1–2 min to index.",
12+
"content": "# AssemblyAI CLI — Usage & Reliability\nBuilt on anonymous usage telemetry (`source:aai-cli`). **Each log line = one command run.** Count tiles work out of the box; the grouped tiles (top commands, outcomes, percentiles, distributions) need facets on `@command`, `@outcome`, `@device_id`, `@duration_ms` (measure), `@os`, `@cli_version`, `@python_version`, `@exit_code`, `@ci`, `@error.message`. Failures ship `status:error` + the reserved `@error.kind` (= the anonymous error type) and `@error.message` (the one-line message the user saw, capped at 500 chars — **no stack trace**), so they also feed **Error Tracking** — the error tiles below filter on `status:error`. Events take ~1–2 min to index.",
1313
"background_color": "purple",
1414
"font_size": "14",
1515
"text_align": "left",
@@ -802,14 +802,113 @@
802802
"width": 12,
803803
"height": 5
804804
}
805+
},
806+
{
807+
"definition": {
808+
"title": "Top error messages",
809+
"type": "toplist",
810+
"requests": [
811+
{
812+
"response_format": "scalar",
813+
"queries": [
814+
{
815+
"name": "errors",
816+
"data_source": "logs",
817+
"search": {
818+
"query": "source:aai-cli status:error"
819+
},
820+
"indexes": [
821+
"*"
822+
],
823+
"compute": {
824+
"aggregation": "count"
825+
},
826+
"group_by": [
827+
{
828+
"facet": "@error.message",
829+
"limit": 15,
830+
"sort": {
831+
"aggregation": "count",
832+
"order": "desc"
833+
}
834+
}
835+
]
836+
}
837+
],
838+
"formulas": [
839+
{
840+
"formula": "errors"
841+
}
842+
]
843+
}
844+
]
845+
},
846+
"layout": {
847+
"x": 0,
848+
"y": 13,
849+
"width": 5,
850+
"height": 4
851+
}
852+
},
853+
{
854+
"definition": {
855+
"title": "Recent errors",
856+
"type": "list_stream",
857+
"requests": [
858+
{
859+
"response_format": "event_list",
860+
"columns": [
861+
{
862+
"field": "status_line",
863+
"width": "auto"
864+
},
865+
{
866+
"field": "timestamp",
867+
"width": "auto"
868+
},
869+
{
870+
"field": "@command",
871+
"width": "auto"
872+
},
873+
{
874+
"field": "@error.kind",
875+
"width": "auto"
876+
},
877+
{
878+
"field": "@error.message",
879+
"width": "full"
880+
},
881+
{
882+
"field": "@cli_version",
883+
"width": "auto"
884+
}
885+
],
886+
"query": {
887+
"data_source": "logs_stream",
888+
"query_string": "source:aai-cli status:error",
889+
"indexes": [],
890+
"sort": {
891+
"column": "timestamp",
892+
"order": "desc"
893+
}
894+
}
895+
}
896+
]
897+
},
898+
"layout": {
899+
"x": 5,
900+
"y": 13,
901+
"width": 7,
902+
"height": 4
903+
}
805904
}
806905
]
807906
},
808907
"layout": {
809908
"x": 0,
810909
"y": 12,
811910
"width": 12,
812-
"height": 14
911+
"height": 18
813912
}
814913
},
815914
{
@@ -959,7 +1058,7 @@
9591058
},
9601059
"layout": {
9611060
"x": 0,
962-
"y": 26,
1061+
"y": 30,
9631062
"width": 12,
9641063
"height": 5
9651064
}
@@ -1170,7 +1269,7 @@
11701269
},
11711270
"layout": {
11721271
"x": 0,
1173-
"y": 31,
1272+
"y": 35,
11741273
"width": 12,
11751274
"height": 5
11761275
}

tests/__snapshots__/test_cli_output_snapshots.ambr

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,8 @@
776776
--out. Speaker-labeled input (from 'assembly transcribe
777777
--speaker-labels') is detected automatically: the labels are stripped
778778
and each speaker gets a different voice. This feature only exists in
779-
the sandbox today — run it as 'assembly --sandbox speak'.
779+
the sandbox today — run it as 'assembly --sandbox speak' (--sandbox
780+
goes before the subcommand).
780781

781782
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
782783
│ text [TEXT] Text to speak. Omit to read from stdin. │

0 commit comments

Comments
 (0)