Rust CLI for interacting with AppSignal. Binary name is appsignal-cli (not
appsignal, to avoid conflicts with the AppSignal Ruby gem).
src/
main.rs CLI entrypoint, clap derive command/subcommand definitions
config.rs Config load/save/delete (~/.config/appsignal/config.toml)
api.rs AppSignalClient — GraphQL client for the AppSignal API
oauth.rs OAuth PKCE flow (code verifier, challenge, token exchange, refresh)
commands/
mod.rs Shared helpers (resolve_org, authenticated_client) + re-exports
auth.rs auth login / logout / status (OAuth only)
apps.rs apps list / info / find / set-org / show-org
incidents.rs incidents list / list-exceptions / list-performance / list-anomalies / show
logs.rs logs tail / search / views / sources
feedback.rs feedback (posts CLI feedback to /api/cli/feedback)
skill.rs skill install (writes bundled AppSignal skills for OpenCode, Codex, or Claude)
- CLI framework: clap v4 with derive macros
- HTTP client: reqwest v0.12 with JSON + rustls-tls
- Async runtime: tokio
- Error handling: anyhow with contextual messages
- Config format: TOML via the
tomlcrate - Config location:
~/.config/appsignal/config.toml(viadirscrate) - Time handling: chrono for UTC timestamps in log tailing/pagination
- Testing: wiremock for HTTP mocking, tempfile for config tests
- Before committing Rust changes, run
cargo fmtandcargo clippy -- -D warnings. - Prefer enums over free-form
Stringvalues when the API exposes a small known domain, especially for GraphQL/JSON fields likestate,kind, orsource. Use serde renames like#[serde(rename_all = "SCREAMING_SNAKE_CASE")]when the wire format is enum-shaped. - In command-layer response/view models, prefer owned
String/Vec<T>data over adding fresh lifetimes just to avoid small clones. Keep borrowing where it is already entrenched, but new code should default to the simpler owned shape. - When adding new user-facing CLI commands or new server endpoints for CLI features, preserve the minimal CLI telemetry flow so command runs still emit the dedicated telemetry event and any new endpoint continues to send the standard CLI headers.
- Prefer descriptive branch names. Avoid cliché names like
fix-bugorwip, keep names longer than 3 characters, avoid leading/trailing punctuation, and do not use a ticket number as the whole branch name. - Prefer rebasing feature branches instead of merging base branches back into
them. Avoid local merge commits from
git pull; usegit pull --rebase(or a Git config likepull.rebase=trueorpull.ff=only) instead. - Do not leave
fixup!,squash!, or amend-style commits in a branch that is ready for review. Autosquash them before pushing or merging. - Write commit subjects in imperative mood, with a capitalized first letter, without trailing punctuation, and keep them under 50 characters.
- Do not use conventional-commit prefixes like
fix:orfeat:in the subject. Do not put ticket numbers,[skip ci], or similar tags in the subject either; move that metadata into the commit body. - Avoid vague subjects like
Fix bug,Update README, orWIP. The subject should describe the actual change. - Always include a commit body separated from the subject by a blank line. Explain why the change was needed, what was changed, and any important constraints or rejected alternatives.
- Do not put literal escaped newline sequences like
\nin commit subjects or bodies. Pass multiple-mflags or otherwise write real newlines instead. - Wrap commit body lines at 72 characters when practical. URLs may stay on one line.
- Put ticket references like
Closes #123orRelated #123in the body, not in the subject. - Keep trailer lines like
Co-authored-by:andSigned-off-by:at the end of the commit body. - In this repo, when a code change does not need a user-facing changeset,
include
[skip changeset]in the commit body rather than the subject.
The CLI currently uses a mix of GraphQL and REST v2 endpoints:
GraphQL: POST https://appsignal.com/graphql
REST v2: https://appsignal.com/api/v2/...
The configured endpoint value is a base URL.
It defaults to https://appsignal.com/, and the client derives /graphql or
/api/v2/... paths from that base.
The CLI authenticates with OAuth (PKCE). Access tokens are sent via an
Authorization: Bearer header and obtained through the OAuth authorization
code flow with PKCE.
The AppSignalClient struct stores an AuthMethod enum that determines how
each request is authenticated:
- GraphQL with
AuthMethod::OAuth { access_token, .. }→ setsAuthorization: Bearer <token> - REST v2 with
AuthMethod::OAuth { access_token, .. }→ setsAuthorization: Bearer <token>
- There is no
appsroot query. The root query type only hasapp(id: ...)for a single app andorganization(slug: ...)for org-level access. - Listing apps requires going through
organization(slug) { apps { ... } }, which means the user must provide their organization slug (from the AppSignal URL:appsignal.com/<org-slug>). - The
Apptype has these known fields:id,name,environment,status,createdAt,incidents,exceptionIncidents,performanceIncidents,anomalyIncidents,logIncidents,deployMarkers,metrics, and more. nameandenvironmentareNON_NULL StringonApp.- GraphQL errors are returned with HTTP 400, not in the
errorsarray of a 200 response. The client handles both cases. - The GraphQL API does not expose
start/endtime range filters for incidents. The MCP server achieves this via direct MongoDB access. - The GraphQL
statefilter only accepts a singleIncidentStateEnumvalue, not a list (unlike the MCP server which supports comma-separated states).
Incident is a GraphQL union of four types:
ExceptionIncident— error/exception incidentsPerformanceIncident— slow action incidentsAnomalyIncident— anomaly detection incidentsLogIncident— log-based incidents
Common fields across all incident types:
id,number(Int!),state(IncidentStateEnum),severity(IncidentSeverityEnum)description,count(Int!),createdAt,lastOccurredAt,updatedAt
Extra fields on ExceptionIncident:
exceptionName,exceptionMessage,actionNames,namespace,firstBacktraceLine
Extra fields on PerformanceIncident:
actionNames,namespace,mean(Float!),totalDuration(Float!)
Extra fields on AnomalyIncident:
alertState(AlertStateEnum),trigger(Trigger object withid,name,metricName,kind)tags(list ofKeyStringValuewithkeyandvalue)
IncidentStateEnum:OPEN,CLOSED,WIPIncidentOrderEnum:ID(creation order),LAST(most recent activity)AlertStateEnum:OPEN,CLOSED,WARMUP,COOLDOWN,UNTRACKED,ARCHIVED
Root query fields:
app(id: String!)— single app by IDorganization(slug: String!)— organization by slugviewer— authenticated user info includingorganizations
Key fields on App:
id,name,environmentincidents(namespaces, marker, state, actionName, limit, offset, order)— all incident types (union)incident(incidentNumber: Int!)— single incident by number (union)exceptionIncidents(namespaces, marker, query, state, limit, offset, order, actionName)— exception incidents only, with text search viaqueryperformanceIncidents(namespaces, marker, query, state, limit, offset, order, actionName)anomalyIncidents(state, limit, offset, order)— anomaly incidents only (fewer filters than exceptions)logIncidents(state, limit, offset, order)deployMarkers(limit, offset, start, end)metrics { list(...) },metrics { timeseries(...) }uptimeMonitors
See https://docs.appsignal.com/api/graphql/examples.html for full examples.
There is also a legacy REST API at https://appsignal.com/api/[app_id]/... for:
/graphs.json— graph data (mean, count, ex_count, ex_rate, pct)/markers.json— deploy markers/samples.json— transaction samples/event_names.json— event names/sourcemaps— sourcemap uploads
That legacy REST API uses the same ?token= query parameter for auth (personal
tokens) or Authorization: Bearer header (OAuth tokens).
The global config file at ~/.config/appsignal/config.toml stores:
org— default organization slug (auto-saved byapps list, or set viaapps set-org)endpoint— (optional) custom AppSignal base URL, defaults tohttps://appsignal.com/oauth_client_id— (optional) OAuth client ID override; defaults to the production client ID when unset[oauth]— OAuth credentials (set viaauth login):access_token— OAuth access tokenrefresh_token— OAuth refresh token (used for automatic renewal)expires_at— UNIX timestamp when the access token expires The org slug is used as a default for all commands that need an organization. It can always be overridden with--org <slug>.
If the current directory (or one of its parents) contains .appsignal.toml, the
CLI uses that nearest local file as the only config for the project. Config
reads and writes go to the active file, so project-specific auth, endpoint, and
org settings stay isolated from the global config.
The CLI uses the OAuth 2.0 Authorization Code flow with PKCE for browser-based
authentication. Implementation is in src/oauth.rs.
- CLI generates a PKCE code verifier + S256 challenge and a random state parameter
- Opens the browser to
<oauth-base>/oauth/authorizewith PKCE and state params - User authorizes in the browser; server redirects to the configured callback URI
- The CLI receives the callback automatically on
http://127.0.0.1:9789/callback - CLI validates the state, then exchanges the code for tokens via
POST <oauth-base>/oauth/token - Credentials are saved to
config.toml
| Setting | Value |
|---|---|
| Default client ID | FpXP78S_vXNrjSRYQIMWQ9sREl2AXS0qD0VSWwfHST0 |
| Default redirect URI | http://127.0.0.1:9789/callback |
| Scopes | user:read app:read app:write |
| PKCE method | S256 |
| Grant types | authorization_code, refresh_token |
The OAuth base defaults to https://appsignal.com and is derived from endpoint
when a custom endpoint is configured, for example https://staging.lol.
The CLI uses the production OAuth client ID by default for all builds. To use a
staging-specific OAuth client, set oauth_client_id in config.toml.
The OAuth provider is Doorkeeper, configured in appsignal-server. The CLI
application is registered as a non-confidential (public) client — no client
secret is used.
Before each API call, commands::authenticated_client() checks if the stored
OAuth access token has expired (with a 60-second grace window). If expired and
a refresh token is available, it automatically refreshes the token and persists
the updated credentials. If refresh fails, the user is prompted to re-authenticate.
| Command | Description |
|---|---|
appsignal-cli auth login [--endpoint URL] [--rest-endpoint URL] [--oauth-client-id ID] [--org SLUG] |
Authenticate via OAuth PKCE flow using the active config for the current project or the global config |
appsignal-cli auth logout |
Delete stored credentials from the active config |
appsignal-cli auth status |
Show auth status and expiry |
appsignal-cli project init [--endpoint URL] [--oauth-client-id ID] [--org SLUG] |
Create or update the project-local .appsignal.toml, which becomes the only config used in that project |
appsignal-cli apps list |
List apps for the current OAuth account and save the default org to the active config |
appsignal-cli apps info --app-id <id> |
Show details for a single app by ID |
appsignal-cli apps find --name <name> [--environment <env>] [--org <slug>] |
Find app by name (case-insensitive) |
appsignal-cli apps set-org --org <slug> |
Set the default organization slug in the active config |
appsignal-cli apps show-org |
Show the current default organization |
appsignal-cli incidents list [options] |
List all incident types for an app |
appsignal-cli incidents list-exceptions [options] |
List exception incidents (supports --query text search) |
appsignal-cli incidents list-performance [options] |
List performance incidents (supports --query text search) |
appsignal-cli incidents list-anomalies [options] |
List anomaly detection incidents (shows trigger/alert info) |
appsignal-cli incidents show --number <N> [app options] |
Show full details for a specific incident |
appsignal-cli incidents update --number <N[,N...]> [--state S] [--severity S] [--assign IDs] [--assign-me] [--description D] |
Update incident state, severity, or assignees; multiple numbers currently support --state only |
appsignal-cli incidents add-note --number <N> --content "..." |
Add a note to an incident (markdown supported) |
appsignal-cli logs tail [filters] |
Stream log lines in real time (1-second polling) |
appsignal-cli logs search [filters] [--page-all] |
One-shot log search (supports auto-pagination and global --output json) |
appsignal-cli logs views [app options] |
List saved log views (filter presets) |
appsignal-cli logs sources [app options] |
List log sources for an app |
appsignal-cli feedback [MESSAGE] [--email <email>] [--no-email] |
Send CLI feedback about missing endpoints, missing features, or broken behavior; reads stdin when MESSAGE is omitted and stores --email in the active config |
appsignal-cli skill install [--target TARGET] [--dir PATH] [--force] |
Install the bundled AppSignal LLM skill for OpenCode, Codex, or Claude |
appsignal-cli skill update [--target TARGET] [--dir PATH] |
Update an installed AppSignal LLM skill to the bundled version |
appsignal-cli skill status [--target TARGET] [--dir PATH] |
Show whether installed AppSignal LLM skills are current, outdated, missing, or unversioned; defaults to all supported targets |
All incident commands accept either:
--app-id <id>— direct app ID (takes priority)--app <name> --environment <env>— look up app by name and environment within the saved org
The --environment flag is only needed when multiple apps share the same name
(e.g. "Weekmenu" in both "development" and "production").
Common options for incidents list, list-exceptions, list-performance, and list-anomalies:
| Flag | Description |
|---|---|
--app <name> |
App name (case-insensitive) |
--environment <env> |
Environment filter (e.g. "production") |
--app-id <id> |
App ID (alternative to --app) |
--org <slug> |
Organization (uses saved default if omitted) |
--limit <N> |
Max results (default: 10) |
--offset <N> |
Pagination offset |
--state <STATE> |
Filter by state: OPEN, CLOSED, or WIP |
--order <ORDER> |
Sort by: LAST (recent activity, default) or ID (creation) |
Additional options for incidents list, list-exceptions, and list-performance:
| Flag | Description |
|---|---|
--namespaces <ns> |
Filter by namespaces (comma-separated, e.g. "web,background") |
--action <name> |
Filter by action name (e.g. "UsersController#show") |
Additional option for list-exceptions and list-performance:
| Flag | Description |
|---|---|
--query <text> |
Text search for name or message |
Finding the latest incident:
# One-time setup: save the org slug from the current OAuth account
appsignal-cli apps list
# Get the latest incident
appsignal-cli incidents list --app "MyApp" --environment "production" --limit 1 --order LAST
# Get full details
appsignal-cli incidents show --number 42 --app "MyApp" --environment "production"Searching for a specific error:
appsignal-cli incidents list-exceptions --app "MyApp" --environment "production" --query "TimeoutError" --state OPENSearching for slow actions:
appsignal-cli incidents list-performance --app "MyApp" --environment "production" --query "UsersController" --state OPENChecking anomaly alerts:
appsignal-cli incidents list-anomalies --app "MyApp" --environment "production" --state OPENFiltering by namespace:
appsignal-cli incidents list --app "MyApp" --environment "production" --namespaces "background" --state OPENClosing an incident:
appsignal-cli incidents update --number 42 --app "MyApp" --environment "production" --state CLOSEDTriaging an incident with severity and a note:
appsignal-cli incidents update --number 42 --app "MyApp" --environment "production" --severity CRITICAL
appsignal-cli incidents add-note --number 42 --app "MyApp" --environment "production" --content "Investigated: root cause is a memory leak in the connection pool."Log data lives in ClickHouse (not MongoDB). The AppSignal server has two paths for querying logs:
- GraphQL (
app.logs.lines) — deprecated and removed server-side. - REST API (
POST /api/v2/logs/lines) — used by the CLI forlogs searchandlogs tail. Supports cursor-based pagination and SSE streaming.
The CLI still uses GraphQL for logs views and logs sources metadata.
LogLine — a single log entry:
id(String!) — UUIDtimestamp(PreciseDateTime!) — ISO 8601severity(SeverityEnum!) — UNKNOWN, TRACE, DEBUG, INFO, NOTICE, WARN, ERROR, CRITICAL, ALERT, FATALhostname(String!) — originating hostgroup(String) — log group (e.g. "notifiers", "background")message(String!) — the log message bodyattributes([KeyStringValue]) — key-value metadatasource(Source) — which log source (lazy-loaded)
LogView — a saved filter preset:
id,name,query(free-text search),sourceIds,severities,columns,lineHeight
LogSource — a log ingestion source:
id,name,type(vector/custom/vercel),fmt(JSON/PLAINTEXT/LOGFMT/AUTODETECT),key
# Fetch log views
app(id: $appId) {
logViews { id name query sourceIds severities columns }
}
# Fetch single log view by ID
app(id: $appId) {
logView(id: $viewId) { id name query sourceIds severities columns }
}
# Fetch log sources
app(id: $appId) {
logs {
sources { id name type fmt }
}
}// Fetch log lines
POST /api/v2/logs/lines
{
"site_id": "app-id",
"from": "2025-06-01T00:00:00Z",
"to": "2025-06-01T01:00:00Z",
"source_ids": ["source-1"],
"query": "message:timeout severity=[error]",
"pagination": {
"per_page": 100,
"order": "desc",
"cursor": { "time": null }
}
}The query parameter supports a rich filter syntax parsed by the ClickHouse BulkEndpoint
Rust service. Full docs: https://docs.appsignal.com/logging/query-syntax
| Operator | Syntax | Meaning | Example |
|---|---|---|---|
= |
field=value |
Exact match | group=notifiers, severity=error |
!= |
field!=value |
Not equal | source!=mongodb |
: |
field:value |
Contains (substring) | message:timeout, hostname:prod |
!: |
field!:value |
Does not contain | hostname!:test |
> < >= <= |
field>value |
Numeric comparison | duration>100 |
severity, hostname, group, message, plus any custom attribute (dot notation
for nested: user.id=123).
Bare words (no field prefix) search the message field: timeout finds messages
containing "timeout".
- Spaces = implicit AND:
severity=error hostname=web-1 ORkeyword:severity=error OR severity=warning- Parentheses:
severity=error AND (hostname=web-1 OR hostname=web-2)
Use quotes for values with spaces or special characters: group:"background jobs",
message:"[Email]".
IMPORTANT: Square brackets [...] have special meaning (list/array syntax in
the legacy query parser). If you want to search for a literal [Email] in messages,
you MUST use message:"[Email]" — NOT [Email] as bare text.
Correct: group=notifiers message:"[Email]"
Wrong: group=notifiers [Email] (the [Email] gets parsed as a list, matching
any line containing the word "email" case-insensitively, not the literal [Email] prefix)
The CLI merges the --severities flag into the REST query string as
severity=[error,critical]. Using the flag is preferred over embedding
severity filters manually in --query.
The --view flag on tail and search resolves a log view by:
- Trying the value as a view ID via
get_log_view(app_id, view_id) - If that fails, listing all views and matching by name (case-insensitive)
- Errors if no match or multiple matches
The view's query, sourceIds, and severities are applied as defaults.
Explicit CLI flags (--query, --severities, --source-ids) always override
the view's values.
The REST POST /api/v2/logs/lines endpoint is still queried in pages of 100.
The --page-all flag on logs search iterates through those pages using the
timestamp cursor:
- Fetch 100 lines in ASC order (oldest first)
- If the page is full (100 results), use the last line's timestamp as the next page cursor
- Deduplicate by log line ID using a
HashSet— multiple lines can share the exact same timestamp, so the boundary between pages may include duplicates - Stop when a page returns fewer than 100 results
This is implemented in fetch_all_pages() in commands/logs.rs.
Progress is printed to stderr: Page N: fetched X lines (Y total unique).
Live log tailing currently uses the REST log query endpoint with polling (not SSE yet):
- Start with a 60-second lookback window
- Every 1 second, query for lines between
startandnowin ASC order - Deduplicate by ID using a
HashSet - Move the window forward: next
start=now - 120 seconds(2-minute lookback for safety) - Prune the seen-ID set when it exceeds 1000 entries (re-seed from current batch)
This matches the frontend's live tail behavior in useLogTail.js.
| Command | Description |
|---|---|
logs tail [filters] |
Real-time log streaming (1s poll interval) |
logs search [filters] [--page-all] |
One-shot log query |
logs views |
List saved log views for an app |
logs sources |
List log sources for an app |
Filter flags (shared by tail and search):
--query <text>— free-text search with field filter syntax--severities <list>— comma-separated (e.g.ERROR,CRITICAL)--source-ids <list>— comma-separated source IDs--view <name-or-id>— apply a saved log view's filters
Additional flags for search:
--start <ISO8601>— start time--end <ISO8601>— end time--limit <N>— max results (default 100, max 100)--order <ASC|DESC>— sort order (default DESC)--output <human|json>— global result format for programmatic/LLM consumption--page-all— auto-paginate to fetch all results (ignores --limit/--order)
Search for recent errors:
appsignal-cli --output json logs search --app "MyApp" --environment "production" \
--severities ERROR,CRITICALCount emails sent in a time window:
appsignal-cli --output json logs search --app "appsignal" --environment "production" \
--query "group:notifiers [Email]" \
--start "2025-03-16T06:53:00Z" --page-allTail logs with a saved view:
appsignal-cli logs tail --app "MyApp" --environment "production" --view "Error logs"Get log sources to use as filter:
appsignal-cli logs sources --app "MyApp" --environment "production"
# Then use a source ID:
appsignal-cli --output json logs search --app "MyApp" --environment "production" \
--source-ids "636873bc14ad665402d297e2"When adding new fields to queries, always verify the field exists on the type first. The AppSignal GraphQL schema is not publicly documented in full. Use introspection queries to discover available fields:
{
__type(name: "App") {
fields {
name
type { name kind }
args { name type { name kind } }
}
}
}The GraphQL endpoint returns HTTP 400 with error details for invalid fields, which makes it easy to iterate, but avoid guessing field names in production queries.
The MCP server (devenv/appsignal-server/app/mcp/tools/) has direct MongoDB
access, giving it capabilities the GraphQL API does not expose:
- Time range filters (
start/end) on incident queries — not available in GraphQL - Multiple state filters (comma-separated) — GraphQL only accepts a single enum value
- Trigger ID filter on anomaly incidents — not available in GraphQL
- Incident mutations (update state/severity/assignees, create notes) — need to discover GraphQL mutations
- Metrics REST API — metric names, tags, timeseries, and aggregations use a separate REST API (
/api/v2/metrics/...) - Log views/sources metadata — still GraphQL-backed; there is no documented v2 REST
replacement for
logs viewsorlogs sourcesyet.
See TODO.md for the full feature parity tracking document.