diff --git a/.gitignore b/.gitignore index f65b89b32c..cf27304fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -406,3 +406,6 @@ Mentra-Zephyr-Glasses-Client/ # Navigation miniapp build output miniapps/navigation/build/ + +# Downloaded bug report bundles (scripts/fetch-incident-logs.sh) +incident-logs/ diff --git a/AGENTS.md b/AGENTS.md index c22a8fdfdd..f0828de2fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,28 +168,30 @@ Automated ransomware scanners actively target exposed MongoDB instances. Use Mon ## Bug Report Logs -When working on bug reports linked to `console.mentra.glass/admin/incidents/{id}`: +Bug reports and feedback filed from the Mentra App land in the Cloud V2 reports system. Report ids look like `rep_01...` and appear in the reports Slack notifications and in the admin console's Incident system page (admin.mentraglass.com). -1. Extract the incident ID from the URL -2. Fetch logs: `./scripts/fetch-incident-logs.sh {incidentId}` — or use the **mentra-console** MCP server (`cloud/packages/console-mcp`, tools `incident_get` / `incident_get_logs`) from Cursor -3. Requires `MENTRA_AGENT_API_KEY` in your environment (or in MCP `env` / `~/.zshrc` when using `scripts/run-mcp.sh`) +1. Get the report id (from Slack, the admin console, or the user) +2. Fetch it: `./scripts/fetch-incident-logs.sh {reportId}` — downloads `report.json` plus every artifact into `./incident-logs/{reportId}/` +3. Requires `MENTRA_ADMIN_TOKEN` in your environment: an org API key (`msk_...`) whose synthetic email is allowlisted via `CLOUD_CORE_ADMIN_EMAILS`, or a WorkOS access token of an admin user +4. Defaults to prod; use `--env dev|staging` or `MENTRA_CORE_URL` for other environments -The logs JSON contains: +What you get: -- `phoneLogs` - Last 10 min of mobile app logs -- `cloudLogs` - Last 10 min of cloud service logs -- `glassesLogs` - Last 10 min of glasses logs (if available) -- `appTelemetryLogs` - Logs from third-party apps (if telemetry enabled) -- `phoneState` - Snapshot of app state at time of report -- `feedback` - User's bug report description +- `report.json` - Full report document: `kind` (bug/feedback/automatic), `trigger`, `report` (actual/expected behavior, severity, contact email), `feedback`, `context` (phone/glasses/app state snapshot), artifact metadata, and asset rows +- `NN-logs-{source}.json` - Log bundles uploaded by the devices (e.g. phone, glasses), each `{entries: [{timestamp, level, message, source?}]}` +- `NN-screenshot-phone-*.{png,jpg}` - Screenshots attached by the user + +Other modes: `--json` prints the raw report JSON to stdout (no downloads); `--list [--kind ...] [--status ...] [--limit N]` lists recent reports. Example: ```bash -export MENTRA_AGENT_API_KEY=your-api-key -./scripts/fetch-incident-logs.sh 550e8400-e29b-41d4-a716-446655440000 +export MENTRA_ADMIN_TOKEN=msk_your-admin-key +./scripts/fetch-incident-logs.sh rep_01JZWY3V8N0F2E9GQ4T6KXH5RD ``` +Note: the **mentra-console** MCP server (`cloud/packages/console-mcp`, tools `incident_get` / `incident_get_logs`) still targets the legacy V1 incidents API (`/api/agent/incidents`, `X-Agent-Key`) and has not been ported to the V2 reports API yet. + ## Additional Documentation - Mintlify docs: `/mintlify-docs/` diff --git a/scripts/fetch-incident-logs.sh b/scripts/fetch-incident-logs.sh index 4fe189c5c0..0b86fe934d 100755 --- a/scripts/fetch-incident-logs.sh +++ b/scripts/fetch-incident-logs.sh @@ -1,35 +1,207 @@ #!/bin/bash # scripts/fetch-incident-logs.sh -# Fetch incident logs for debugging bug reports -# Usage: ./scripts/fetch-incident-logs.sh - -set -e - -INCIDENT_ID=$1 -API_HOST="${MENTRA_API_HOST:-https://api.mentra.glass}" - -if [ -z "$INCIDENT_ID" ]; then - echo "Usage: ./scripts/fetch-incident-logs.sh " - echo "Example: ./scripts/fetch-incident-logs.sh 01HXYZ..." - echo "" - echo "Required environment variables:" - echo " MENTRA_AGENT_API_KEY - API key for agent access" - echo "" - echo "Optional environment variables:" - echo " MENTRA_API_HOST - API host (default: https://api.mentra.glass)" +# Fetch a Cloud V2 bug report and its artifacts (log bundles, screenshots) +# for debugging. Reports are filed from the Mentra App and stored by the +# Cloud V2 reports service; this script reads them back through the admin +# reports API (GET /api/admin/reports/...). +# +# Usage: +# ./scripts/fetch-incident-logs.sh [options] Download report + artifacts +# ./scripts/fetch-incident-logs.sh --list [options] List recent reports (JSON) +# +# Options: +# -o, --out DIR Output directory (default: ./incident-logs/) +# --json Print the raw report JSON to stdout, skip artifact downloads +# --env ENV prod | staging | dev (default: prod) +# --kind KIND (--list) bug | feedback | automatic +# --status STATUS (--list) collecting | ready | closed +# --limit N (--list) max reports to return (1-200, default 50) +# +# Environment variables: +# MENTRA_ADMIN_TOKEN (required) Bearer token for the admin API: an org API +# key (msk_...) whose synthetic email is allowlisted in +# CLOUD_CORE_ADMIN_EMAILS, or a WorkOS access token of +# an admin user. +# MENTRA_CORE_URL (optional) Core API base URL; overrides --env. + +set -euo pipefail + +usage() { + sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//' + exit 1 +} + +err() { echo "Error: $*" >&2; } +note() { echo "$*" >&2; } + +command -v curl >/dev/null || { err "curl is required"; exit 1; } +command -v jq >/dev/null || { err "jq is required (brew install jq)"; exit 1; } + +REPORT_ID="" +OUT_DIR="" +MODE="fetch" +ENV_NAME="prod" +LIST_KIND="" +LIST_STATUS="" +LIST_LIMIT="" + +while [ $# -gt 0 ]; do + case "$1" in + --list) MODE="list" ;; + --json) MODE="json" ;; + -o|--out) OUT_DIR="${2:?--out requires a directory}"; shift ;; + --env) ENV_NAME="${2:?--env requires prod|staging|dev}"; shift ;; + --kind) LIST_KIND="${2:?--kind requires a value}"; shift ;; + --status) LIST_STATUS="${2:?--status requires a value}"; shift ;; + --limit) LIST_LIMIT="${2:?--limit requires a number}"; shift ;; + -h|--help) usage ;; + -*) err "unknown option: $1"; usage ;; + *) + if [ -n "$REPORT_ID" ]; then err "unexpected argument: $1"; usage; fi + REPORT_ID="$1" + ;; + esac + shift +done + +case "$ENV_NAME" in + prod) DEFAULT_CORE_URL="https://core.mentraglass.com" ;; + staging) DEFAULT_CORE_URL="https://core.staging.us-west-2.mentraglass.com" ;; + dev) DEFAULT_CORE_URL="https://core.dev.us-west-2.mentraglass.com" ;; + *) err "--env must be prod, staging, or dev (got: $ENV_NAME)"; exit 1 ;; +esac +CORE_URL="${MENTRA_CORE_URL:-$DEFAULT_CORE_URL}" +CORE_URL="${CORE_URL%/}" + +if [ "$MODE" != "list" ] && [ -z "$REPORT_ID" ]; then + usage +fi +if [ -n "$REPORT_ID" ] && ! printf '%s' "$REPORT_ID" | grep -Eq '^[A-Za-z0-9_-]+$'; then + err "report id contains unexpected characters: $REPORT_ID" exit 1 fi -if [ -z "$MENTRA_AGENT_API_KEY" ]; then - echo "Error: MENTRA_AGENT_API_KEY environment variable not set" - echo "" - echo "To set this:" - echo " export MENTRA_AGENT_API_KEY=your-api-key" +if [ -z "${MENTRA_ADMIN_TOKEN:-}" ]; then + err "MENTRA_ADMIN_TOKEN environment variable not set" + note "" + note "The admin reports API needs a bearer token with admin access:" + note " - an org API key (msk_...) allowlisted via CLOUD_CORE_ADMIN_EMAILS, or" + note " - a WorkOS access token of an admin user" + note "" + note " export MENTRA_ADMIN_TOKEN=msk_..." exit 1 fi -echo "Fetching incident logs for: $INCIDENT_ID" >&2 -echo "API host: $API_HOST" >&2 +# api_get PATH OUTFILE -> echoes HTTP status; body lands in OUTFILE. +api_get() { + curl -sS -m 60 \ + -H "Authorization: Bearer $MENTRA_ADMIN_TOKEN" \ + -H "Accept: application/json" \ + -o "$2" -w '%{http_code}' \ + "$CORE_URL$1" +} -curl -s -H "X-Agent-Key: $MENTRA_AGENT_API_KEY" \ - "$API_HOST/api/agent/incidents/$INCIDENT_ID/logs" | jq . +fail_for_status() { + local status="$1" body="$2" what="$3" + case "$status" in + 2??) return 0 ;; + 401) err "unauthorized (401) fetching $what — MENTRA_ADMIN_TOKEN was rejected" ;; + 403) err "forbidden (403) fetching $what — token is valid but not admin-allowlisted (CLOUD_CORE_ADMIN_EMAILS)" ;; + 404) err "not found (404) fetching $what — wrong report id, or this environment does not serve the admin reports API yet" ;; + *) err "HTTP $status fetching $what" ;; + esac + if [ -s "$body" ]; then jq . "$body" >&2 2>/dev/null || cat "$body" >&2; fi + exit 1 +} + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +if [ "$MODE" = "list" ]; then + QUERY="" + [ -n "$LIST_KIND" ] && QUERY="$QUERY&kind=$LIST_KIND" + [ -n "$LIST_STATUS" ] && QUERY="$QUERY&status=$LIST_STATUS" + [ -n "$LIST_LIMIT" ] && QUERY="$QUERY&limit=$LIST_LIMIT" + QUERY="${QUERY#&}" + note "Listing reports from $CORE_URL${QUERY:+ ($QUERY)}" + BODY="$TMP_DIR/list.json" + STATUS=$(api_get "/api/admin/reports${QUERY:+?$QUERY}" "$BODY") + fail_for_status "$STATUS" "$BODY" "report list" + jq . "$BODY" + exit 0 +fi + +note "Fetching report $REPORT_ID from $CORE_URL" +DETAIL="$TMP_DIR/detail.json" +STATUS=$(api_get "/api/admin/reports/$REPORT_ID" "$DETAIL") +fail_for_status "$STATUS" "$DETAIL" "report $REPORT_ID" + +if [ "$MODE" = "json" ]; then + jq . "$DETAIL" + exit 0 +fi + +OUT_DIR="${OUT_DIR:-./incident-logs/$REPORT_ID}" +mkdir -p "$OUT_DIR" +jq . "$DETAIL" > "$OUT_DIR/report.json" + +KIND=$(jq -r '.report.kind' "$DETAIL") +REPORT_STATUS=$(jq -r '.report.status' "$DETAIL") +ARTIFACT_COUNT=$(jq '.report.artifacts | length' "$DETAIL") +note "Report kind: $KIND, status: $REPORT_STATUS, artifacts: $ARTIFACT_COUNT" +if [ "$REPORT_STATUS" = "collecting" ]; then + note "Note: status is 'collecting' — the device may still be uploading artifacts" +fi + +ext_for_content_type() { + case "${1%%;*}" in + application/json) echo "json" ;; + image/png) echo "png" ;; + image/jpeg) echo "jpg" ;; + image/webp) echo "webp" ;; + image/gif) echo "gif" ;; + text/*) echo "txt" ;; + *) echo "bin" ;; + esac +} + +# Artifact type/source/filename are report metadata (source and filename are +# client-supplied free text), so strip anything path-like before they can +# shape a local filename. +sanitize_component() { + printf '%s' "$1" | tr -cd 'A-Za-z0-9._-' | cut -c1-40 +} + +FAILED=0 +INDEX=0 +while IFS=$'\t' read -r ARTIFACT_ID TYPE SOURCE CONTENT_TYPE FILENAME; do + [ -n "$ARTIFACT_ID" ] || continue + INDEX=$((INDEX + 1)) + EXT=$(ext_for_content_type "$CONTENT_TYPE") + SAFE_TYPE=$(sanitize_component "$TYPE") + SAFE_SOURCE=$(sanitize_component "$SOURCE") + SAFE_NAME=$(sanitize_component "$FILENAME") + SAFE_NAME="${SAFE_NAME%.*}" + FILE=$(printf '%02d-%s-%s%s.%s' "$INDEX" "${SAFE_TYPE:-artifact}" "${SAFE_SOURCE:-unknown}" "${SAFE_NAME:+-$SAFE_NAME}" "$EXT") + BODY="$TMP_DIR/artifact" + STATUS=$(api_get "/api/admin/reports/$REPORT_ID/artifacts/$ARTIFACT_ID" "$BODY") || STATUS="000" + if ! printf '%s' "$STATUS" | grep -q '^2'; then + err "artifact $ARTIFACT_ID ($TYPE/$SOURCE) failed with HTTP $STATUS — skipping" + FAILED=$((FAILED + 1)) + continue + fi + if [ "$EXT" = "json" ] && jq . "$BODY" > "$TMP_DIR/pretty" 2>/dev/null; then + mv "$TMP_DIR/pretty" "$OUT_DIR/$FILE" + else + mv "$BODY" "$OUT_DIR/$FILE" + fi + note " saved $FILE" +done < <(jq -r '.report.artifacts[] | [.artifactId, .type, .source, (.contentType // ""), (.filename // "")] | @tsv' "$DETAIL") + +echo "$OUT_DIR" +ls -lh "$OUT_DIR" >&2 + +if [ "$FAILED" -gt 0 ]; then + err "$FAILED artifact(s) failed to download" + exit 1 +fi