# Rustrak documentation # Overview Rustrak is a self-hosted error tracker that speaks the Sentry protocol. Any Sentry SDK you already use can send to it: change the DSN and nothing else. ## How it works ```text Your app → Sentry SDK → Rustrak server → SQLite or PostgreSQL (API + dashboard on :8080) ``` The SDK sends events to the server. The server groups them into issues and stores them in the database. The dashboard is compiled into the same server, so one process on one port answers both the API and the UI. It is one container of about 20 MB that idles under 100 MB of memory, with ingestion under 50 ms. A VPS with 512 MB to 1 GB runs it comfortably. There is no per-event pricing and no event limit. ## Database backends Each Docker image is compiled for one database. Both include the dashboard. | Image tag | Backend | Use it for | |-----------|---------|------------| | `latest`, `vX.Y.Z` | SQLite | Personal projects and low traffic. Nothing else to run | | `postgres`, `vX.Y.Z-postgres` | PostgreSQL | Teams, high volume, concurrent writes | SQLite is the default. [Database backends](https://rustrak.dev/configuration/database) compares the two. ## What it tracks - Errors and exceptions, with stack traces and source context, grouped into [issues](https://rustrak.dev/usage/issues) that you resolve, archive or reopen - [Source maps](https://rustrak.dev/usage/source-maps) for JavaScript, including Hermes and React Native, and [debug files](https://rustrak.dev/usage/debug-files) for Android, JVM and iOS - Transactions and spans - Release health and sessions - [Logs](https://rustrak.dev/usage/logs) - [Crons](https://rustrak.dev/usage/crons): missed, failed and overrunning scheduled jobs - [AI agent](https://rustrak.dev/usage/agents) traces - [User feedback](https://rustrak.dev/usage/user-feedback), with its screenshots Around it: [alerts](https://rustrak.dev/usage/alerts) to Slack, email and webhooks, [teams and roles](https://rustrak.dev/usage/team), [API tokens](https://rustrak.dev/usage/tokens), [GitHub](https://rustrak.dev/configuration/github) and [GitLab](https://rustrak.dev/configuration/gitlab) integration, [single sign-on](https://rustrak.dev/configuration/sso), [storage and retention](https://rustrak.dev/usage/storage) and an [MCP server](https://rustrak.dev/sdks/mcp) for AI assistants. ## What it does not track The server accepts these from an SDK and drops them: - Attachments on error events - Session replays - Profiling ## Next [Install Rustrak](https://rustrak.dev/getting-started/installation), then follow the [quickstart](https://rustrak.dev/getting-started/quickstart) to your first issue. --- # Installation Rustrak is one container. It takes events, answers the API and serves the dashboard, all on port `8080`. ## Docker The default image keeps everything in SQLite, so no database runs next to it: ```bash docker run -d \ --name rustrak \ -p 8080:8080 \ -v rustrak_data:/data \ -e SESSION_SECRET_KEY=$(openssl rand -hex 32) \ -e CREATE_SUPERUSER=admin@example.com:changeme123 \ -e PUBLIC_URL=http://localhost:8080 \ rustrak/rustrak-server:latest ``` Open `http://localhost:8080` and sign in with the email and password from `CREATE_SUPERUSER`. - `rustrak_data` holds the database and uploaded source maps. Keep the volume and the data survives restarts and upgrades. - `SESSION_SECRET_KEY` signs the session cookie. Keep the same value across restarts, or everyone is signed out. - `CREATE_SUPERUSER` is `email:password`. It creates the first account only while the database has no users; after that it is ignored, so changing it does not change a password. - `PUBLIC_URL` is the address your SDKs and your browser reach. It becomes the host in every DSN. Without it, DSNs carry the bind address (`0.0.0.0:8080`), which nothing can reach. SQLite suits personal projects, small teams and up to about 1,000 events an hour. For more, use [PostgreSQL](#postgresql). ## Docker Compose The repository's [`docker-compose.yml`](https://github.com/rustrak/rustrak/blob/main/docker-compose.yml) runs the same setup. Download it and put a `.env` next to it: ```bash curl -O https://raw.githubusercontent.com/rustrak/rustrak/main/docker-compose.yml cat > .env <> .env docker compose -f docker-compose.postgres.yml up -d ``` `POSTGRES_USER` and `POSTGRES_DB` default to `rustrak`. The `rustrak_data` volume still holds uploaded source maps; the events live in PostgreSQL. To use a database you already run, take the `rustrak/rustrak-server:postgres` image on its own and set `DATABASE_URL`: ```bash -e DATABASE_URL=postgres://user:password@db.example.com:5432/rustrak ``` See [Database backends](https://rustrak.dev/configuration/database). ## The dashboard The dashboard is compiled into the image and served from `/`, on the same origin as the API. There is nothing to configure for it. Two cases need something extra: - **No dashboard.** Set `RUSTRAK_DASHBOARD=off` and the server answers the API only. - **The dashboard on another host.** Run the server with `RUSTRAK_DASHBOARD=off` and put `rustrak/rustrak-ui` (about 30 MB) on the other host. See [Dashboard on its own host](https://rustrak.dev/configuration/production#dashboard-on-its-own-host). ## Telemetry The server sends one anonymous report every six hours: version, platform and health counters, never your data. Set `RUSTRAK_TELEMETRY=off` to turn it off. [Telemetry](https://rustrak.dev/configuration/telemetry) lists every field. ## Next Sign in. An empty project list means the server is up. [Create your first project](https://rustrak.dev/getting-started/quickstart) and send it an event. --- # Quickstart From a running server to your first issue. You need Rustrak [installed](https://rustrak.dev/getting-started/installation) and the account from `CREATE_SUPERUSER`. ## Create a project Sign in, then go to **Projects → New Project**. Pick the platform you want to monitor, give the project a name and click **Create Project**. Rustrak opens the project's **Settings → SDK** page. It has the DSN, the install command for the platform you picked and a setup snippet with the DSN already filled in. The DSN looks like this: ```text http://32159c3edfd340c28869ce39e4faaa48@localhost:8080/4 ``` If the host in the DSN is `0.0.0.0`, the server has no `PUBLIC_URL`. Set it to the address your applications reach, for example `https://rustrak.example.com`, and restart. See [`PUBLIC_URL`](https://rustrak.dev/configuration/environment#public_url). ## Send an event Rustrak works with the official Sentry SDKs, so install the one for your language: ```bash npm install @sentry/node # Node.js (@sentry/browser in the browser) pip install sentry-sdk # Python go get github.com/getsentry/sentry-go cargo add sentry # Rust ``` Initialize it with your DSN and capture something: ```javascript import * as Sentry from '@sentry/node'; Sentry.init({ dsn: 'YOUR_DSN' }); Sentry.captureException(new Error('Something went wrong')); ``` ```python import sentry_sdk sentry_sdk.init(dsn="YOUR_DSN") try: 1 / 0 except ZeroDivisionError as e: sentry_sdk.capture_exception(e) ``` ```go import ( "errors" "time" "github.com/getsentry/sentry-go" ) sentry.Init(sentry.ClientOptions{Dsn: "YOUR_DSN"}) defer sentry.Flush(2 * time.Second) sentry.CaptureException(errors.New("something went wrong")) ``` ```rust let _guard = sentry::init("YOUR_DSN"); sentry::capture_message("Something went wrong", sentry::Level::Error); ``` The SDKs send in the background. A script that exits right away can quit before the event leaves: Go needs the `Flush` above, and in Node.js call `await Sentry.flush()` before exiting. Python and Rust flush on exit. ## Find the issue Open the project in the dashboard. The event shows up as an issue on its **Issues** list, with its stack trace. Events with the same exception type and message land in the same issue, so sending it again raises the count instead of adding a row. Click **Resolve** on the issue when you have fixed it. If the error happens again, Rustrak reopens it as a regression. ## Next - [Issues](https://rustrak.dev/usage/issues): grouping, states and what an issue shows - [Alerts](https://rustrak.dev/usage/alerts): get told when a new issue appears - [Production checklist](https://rustrak.dev/configuration/production): before real traffic reaches it --- # Projects A project is one application you monitor: a web app, an API, a worker. Each has its own DSN, issues, logs and the rest. The [quickstart](https://rustrak.dev/getting-started/quickstart) shows how to create one. ## Create a project Go to **Projects → New Project**, pick the platform, give it a name and click **Create Project**. The slug is generated from the name; click **Edit slug** to choose your own. Two projects cannot share a slug. Rustrak then opens **Settings → SDK** with the DSN, the install command and a setup snippet for the platform you picked. ## The DSN The DSN is the address a Sentry SDK sends to. It holds the project's public key and ID: ```text http://32159c3edfd340c28869ce39e4faaa48@rustrak.example.com/4 ``` The host comes from the server's [`PUBLIC_URL`](https://rustrak.dev/configuration/environment#public_url). Find the DSN any time under the project's **Settings → SDK**. ## Settings Open a project's settings from **Settings** at the bottom of its sidebar. | Page | What is there | |---|---| | **General** | Name, slug and platform, and **Remove Project** | | **Members** | Who can see the project, and with which role. See [Team & access](https://rustrak.dev/usage/team) | | **SDK** | The DSN and the setup snippet | | **Data** | Which products the project keeps, and its event limits | | **Repository** | The [GitHub](https://rustrak.dev/configuration/github) repository linked to the project | | **Alerts** | The project's [alerts](https://rustrak.dev/usage/alerts) | The slug is in every issue's short ID (`STOREFRONT-1`), so renaming it renames those. The DSN and dashboard links use the numeric ID and keep working. ## Turn products off **Settings → Data → Products** lists what the project keeps: User Feedback, Releases, Performance, Logs, Crons and Agents. Issues are always on. Turning a product off removes it from the sidebar and drops its data as it arrives: the SDK keeps sending, Rustrak stops storing. What is already stored stays and shows again when you turn it back on. Turning Performance off turns Agents off too, since AI spans travel inside performance data. Drag the products, or use the arrows, to set the order the sidebar lists them in. ## Event limits **Settings → Data → Event limits** caps how many events the project stores per minute and per hour. Past a limit, Rustrak answers `429` and the SDK waits before sending more. Leave a field empty to use the server's limit, set in **Settings → Instance**; a project's limit can only be lower than the server's. The page also counts the events dropped by a limit. ## Remove a project **Settings → General → Remove Project** deletes the project with all its issues and events. It cannot be undone. ## One project or several Make one project per application: the web front end, the API and the mobile app each get their own. Keep environments of the same app in one project and tell them apart with the SDK's `environment` option. The overview, Issues, Releases, Performance and Agents filter by environment; Crons shows one strip per environment. ```javascript Sentry.init({ dsn: 'YOUR_DSN', environment: 'production', }); ``` --- # Issues Every error an SDK sends is an event. Rustrak groups events caused by the same bug into one issue, so a crash that happens 500 times is one row with a count of 500, not 500 rows. Each issue has a short ID: the project slug in capitals and a number, such as `STOREFRONT-1`. Click it on the issue page to copy it. ## The issue list Open a project to see its issues. The tabs split them by state: **Open**, **Resolved**, **Archived** and **All**. The bar above the table searches the error type, message, transaction and culprit, ignoring case, and filters by any column (priority, assignee, level and so on), by environment or by release. Start typing and it suggests the keys and their values. Select rows with the checkboxes to resolve, archive, assign or delete several issues at once. Deleting removes the issue and all its events, and cannot be undone. ## The issue page Click an issue to open it. The page shows one event at a time, the latest first, and the arrows next to **Event N of M** move through the rest. For each event you get: - **Highlights**: level, handled or not, transaction, URL, environment, release - **Stack Trace**, with the source lines when the SDK sends them or a [source map](https://rustrak.dev/usage/source-maps) or [debug file](https://rustrak.dev/usage/debug-files) supplies them - **Breadcrumbs**: what the app did before the error - **Tags** and **Context**: user, browser, OS and whatever your code added - **Event Details** and the **Raw JSON** as the SDK sent it The sidebar holds what belongs to the issue rather than to one event: status, priority, assignee, event and user counts, the release where it was first and last seen, tag breakdowns, the linked [GitHub](https://rustrak.dev/configuration/github) issue, and an **Activity** feed where your team can leave comments. ## Resolve, archive, reopen | Action | Shortcut | What happens | |---|---|---| | **Resolve** | `R` | The issue moves to **Resolved**. A new event reopens it as **Regressed**. | | **Resolve in next release** | | Stays resolved while events keep arriving from the release it was resolved in. An event from any other release reopens it, an older one included. Events with no release count as the same release. Creating a newer release through the API ends the wait, so from then on any event reopens it. | | **Archive** | `E` | The issue moves to **Archived**. It keeps counting events but sends no alerts and never reopens on its own. **Unarchive** brings it back. | **Resolve in next release** is under the arrow next to **Resolve**. The issue page has more shortcuts: `B` bookmarks the issue, `I` assigns it to you, `Shift+C` copies its link. ## Priority A new issue takes its priority from the event's level: `fatal` and `error` are **High**, `warning` is **Medium**, `info` and `debug` are **Low**, anything else is **Medium**. Change it from the sidebar; your choice sticks. ## How grouping works Two events land in the same issue when they get the same grouping key. By default the key is the exception's type and message, for every exception in a chained error, plus the transaction name. An event without an exception uses its message instead. Before comparing messages, Rustrak replaces the parts that change from one occurrence to the next (numbers, dates, hex values, UUIDs and similar) with placeholders. `Order 1234 not found` and `Order 5678 not found` are one issue. The issue title keeps the real text. The stack trace does not take part. The same error thrown from two places is one issue; two different errors from the same line are two. ## Custom fingerprints To group differently, set a fingerprint in the SDK. Events with the same fingerprint share an issue, whatever their message: ```javascript Sentry.captureException(error, { fingerprint: ['payment-gateway-timeout'], }); ``` `{{ default }}` stands for the default grouping key, so you can split an issue further instead of replacing the key: ```javascript Sentry.captureException(error, { fingerprint: ['{{ default }}', region], }); ``` `{{ default }}` is the only variable Rustrak understands. Sentry's `{{ transaction }}`, `{{ type }}` and the rest are taken as literal text, so pass the value yourself instead. An empty fingerprint falls back to default grouping. Fingerprints only affect events sent after you add them. Issues that already exist keep their events. --- # User Feedback User feedback is what people using your app write to you from inside it: "the page went blank when I paid". Rustrak collects it per project under **User Feedback**, next to **Issues**. ## Sending feedback Use the Sentry SDK's feedback widget, or send feedback from your own form. In JavaScript: ```javascript Sentry.init({ dsn: 'YOUR_DSN', integrations: [Sentry.feedbackIntegration()], }); ``` The widget adds a button to your pages that opens a form with a message, a name, an email and an optional screenshot. From your own form, call: ```javascript Sentry.captureFeedback({ message: 'The page went blank when I pressed Place order.', name: 'Grace Lin', email: 'grace@example.com', associatedEventId: eventId, // optional: the error it is about }); ``` Rustrak also takes the older formats: the `user_report` envelope item and `POST /api/0/projects/{org}/{project}/user-feedback/`. The endpoint needs the `event_id` of the error the report is about and answers 400 without one; for feedback with no error, use `Sentry.captureFeedback()`. The crash-report dialog that `Sentry.showReportDialog()` opens is not supported. A feedback needs a message of at most 4,096 characters. One with an empty or longer message is dropped. Screenshots are kept up to 10 MB each. ## Reading it **User Feedback** has three mailboxes: **Inbox**, **Resolved** and **Spam**. Each row shows who sent it, the message and the page it came from. Open one to see the whole message, when it arrived, its screenshots and a link to the error it is about when the SDK sent one. Resolve feedback once you have dealt with it, or mark it as spam. Either moves it out of the inbox and opens the next one. Underneath, each feedback is an issue: **Open as an issue** shows it on the issue page, with its activity feed and assignee. Feedback linked to an error also appears on that error's issue page, under **User Feedback**, so whoever fixes the error reads what the user saw. ## Alerts Add an [alert rule](https://rustrak.dev/usage/alerts) on **New feedback** to hear about each new feedback in Slack, by email or through a webhook. --- # Releases A release is a version of your app. Tag your events with it and Rustrak tells you, per version, how many sessions crashed and which issues it brought. ## Tag events with a release Set `release` when you initialize the SDK: ```javascript Sentry.init({ dsn: 'YOUR_DSN', release: 'storefront@2.15.0', }); ``` A release shows up under **Releases** as soon as an event or a session carries it. There is nothing to create first. Use the `package@version` form Sentry recommends: the dashboard shows the package and the version apart. A version cannot be empty, `.`, `..` or `latest`, cannot contain `/`, `\`, tabs or line breaks, and is at most 200 characters. ## Release health Release health counts sessions: one per page load or app start, marked as crashed when it ends in an unhandled error. Turn on session tracking in your SDK to send them. Without sessions, a release still lists its events and new issues, and the health columns show `–`. | Column | What it is | |---|---| | **Adoption** | This release's share of all sessions in the window | | **Crash-free** | Sessions that did not crash | | **Crash-free users** | Users who had no crashed session. Needs the SDK to send a user with each session | | **Sessions** | Sessions in the window | | **New issues** | Issues first seen in this release | | **Events** | Error events tagged with this release | The window button (`14d` by default) and the **Environment** filter narrow every number. Search the list by version. ## A release's page Click a release to open it. It shows the same numbers against the project average, healthy and crashed sessions over time, the issues first seen in this release, and each environment the release ran in. **View its issues** opens the issue list filtered to this release. A release is **Unreleased** until it gets a release date (see below). The numbers do not depend on it. ## Create releases from CI To give a release a commit and a release date, create it from your deploy pipeline with an [API token](https://rustrak.dev/usage/tokens): ```bash # Create the release (the organization part of the path is ignored) curl -X POST "$RUSTRAK_URL/api/0/projects/_/storefront/releases/" \ -H "Authorization: Bearer $RUSTRAK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"version": "storefront@2.16.0", "ref": "a1b2c3d"}' # Finalize it: set the release date curl -X PUT "$RUSTRAK_URL/api/0/projects/_/storefront/releases/storefront@2.16.0/" \ -H "Authorization: Bearer $RUSTRAK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"dateReleased": "2026-11-01T20:00:00Z"}' ``` The commands `sentry-cli releases new`, `finalize`, `set-commits`, `deploys` and `list` do not work against Rustrak yet: they call Sentry's organization-level release endpoints, which Rustrak does not serve. `sentry-cli sourcemaps upload` does work; see [Source maps](https://rustrak.dev/usage/source-maps). A release also drives [Resolve in next release](https://rustrak.dev/usage/issues#resolve-archive-reopen): an issue resolved that way reopens when an event arrives from a different release, or from any release once a newer one is created. --- # Performance Performance shows how long each endpoint or page load takes, how often it runs and how often it fails, from the transactions your SDK sends. ## Turn on tracing Set a sample rate in the SDK. `1.0` sends every transaction, `0.2` one in five: ```javascript Sentry.init({ dsn: 'YOUR_DSN', tracesSampleRate: 0.2, }); ``` ```python sentry_sdk.init(dsn="YOUR_DSN", traces_sample_rate=0.2) ``` The SDK names each transaction after the route or page (`POST /api/orders`, `/checkout`) and records spans inside it: database queries, outgoing HTTP calls, cache reads. Rustrak also accepts spans sent on their own, outside a transaction. ## The endpoint list **Performance** groups transactions by name and operation (`http.server`, `pageload`) into endpoints: | Column | What it is | |---|---| | **Time spent** | This endpoint's share of the total time of all endpoints. Start here: it is where speeding up pays off most | | **Calls** | Transactions in the window | | **Average** | Mean duration | | **p95** | 95% of calls were faster than this | | **Failures** | Share of calls whose trace status is not `ok` | The window button (`14d` by default) and the environment filter narrow the numbers. Search the list by name. ## An endpoint's page Click an endpoint to see calls per minute, p50 and p95, failures and time spent, then latency and calls over time, and the slowest samples of the window. Open a sample to see its trace: the transaction and its spans as a waterfall. Click a span for its operation, description, duration, start, status and self time (its duration minus its children's). The request, tags and user the SDK attached are next to the trace. --- # Logs Logs are the structured lines your app writes as it runs: "order created", "carrier quote took 900 ms". Rustrak keeps them per project under **Logs**, apart from issues, so you can see what the app was doing before something broke. They are not grouped: every line is stored and shown as it came. ## Send logs Logs are off by default in Sentry SDKs. Turn them on in `Sentry.init` and use the SDK's logger: ```javascript import * as Sentry from '@sentry/node'; Sentry.init({ dsn: 'YOUR_DSN', enableLogs: true, }); Sentry.logger.info('Order created', { orderId: 9182, total: 54.9 }); Sentry.logger.warn('Carrier quote slow', { carrier: 'dhl', ms: 1830 }); Sentry.logger.error('Payment webhook signature mismatch'); ``` Rustrak takes Sentry's `log` envelope item, so any SDK version that sends logs works. Older JavaScript SDKs put the switch under `_experiments: { enableLogs: true }`. The SDK sends logs in batches, so they show up a few seconds later; call `Sentry.flush()` before a script exits. Each line keeps its level, message, timestamp and attributes, each attribute with its type. A line written inside an active span also carries the span's trace and span IDs. ## Levels | Level | Severity number | |---|---| | `trace` | 1 | | `debug` | 5 | | `info` | 9 | | `warn` | 13 | | `error` | 17 | | `fatal` | 21 | A line sent without a level is stored as `info`. An unknown level is kept as sent, with no severity number. ## Find a line **Logs** shows the newest lines first, with a chart of the volume by level above them. - Type in the search bar to find lines whose message contains the text, ignoring case. - `level:error` keeps one level; `trace:` keeps one trace. - The window button (`14d` by default) sets how far back to look. Click a line to open it: the full time, severity, trace ID and every attribute. **Logs in this trace** filters the list by that line's trace. It keeps the other filters, so remove `level:` to see every line of the request. ## API Read logs with an [API token](https://rustrak.dev/usage/tokens): ```bash curl -H "Authorization: Bearer $RUSTRAK_TOKEN" \ "$RUSTRAK_URL/api/projects/3/logs?level=error&query=renewal&period=7d&per_page=50" ``` | Parameter | What it does | |---|---| | `level` | One level | | `trace_id` | One trace | | `query` | Message contains this text, ignoring case | | `period` | Window such as `24h`, `7d`, `14d`. All time when absent | | `page`, `per_page` | Pagination. `per_page` is 20 by default, at most 100 | `GET /api/projects/{id}/logs/{log_id}` returns one line and `GET /api/projects/{id}/logs/volume` the counts behind the chart. The [MCP server](https://rustrak.dev/sdks/mcp) reads logs too. Logs count toward storage and are removed by retention like the rest of the data. See [Storage](https://rustrak.dev/usage/storage). --- # Crons Errors tell you when code breaks. They cannot tell you when code never ran: a nightly backup whose cron line was deleted, a worker that died, a job hung for six hours. Crons watches your scheduled jobs and opens an issue when a run is missed, fails or runs too long. It is compatible with Sentry Crons: the `check_in` item Sentry SDKs send, the HTTP check-in endpoint and `sentry-cli monitors run` all work against Rustrak unchanged. ## How it works Each job has a monitor, named by a slug. Every run sends check-ins: `in_progress` when it starts, then `ok` or `error` when it ends, with the same check-in ID. From the monitor's schedule Rustrak knows when the next run is due. Once a minute it records what the job did not report itself: | Status | Meaning | |---|---| | Missed | No check-in arrived within the grace period after the expected time | | Timed out | A run stayed `in_progress` longer than its max runtime | | Failed | The job reported `error` | After as many consecutive failures as the failure tolerance, an issue opens, titled `Cron failure: `. It goes through the same pipeline as any error, so your [alerts](https://rustrak.dev/usage/alerts) fire for it, and later failures land in the same issue. After as many consecutive `ok` check-ins as the recovery tolerance, the issue resolves itself. Each environment a job checks in from (`production`, `staging`) is tracked apart, with its own status and its own issue. ## Create a monitor **From code.** Send a `monitor_config` with the check-in. The monitor is created on the job's first run and updated whenever the config changes: ```python from sentry_sdk.crons import monitor monitor_config = { "schedule": {"type": "crontab", "value": "0 3 * * *"}, "timezone": "Europe/Madrid", "checkin_margin": 5, # minutes "max_runtime": 30, # minutes } @monitor(monitor_slug="nightly-backup", monitor_config=monitor_config) def nightly_backup(): ... ``` ```javascript Sentry.withMonitor( 'nightly-backup', async () => { // your job }, { schedule: { type: 'crontab', value: '0 3 * * *' }, checkinMargin: 5, maxRuntime: 30 }, ); ``` **From the dashboard.** Open a project, go to **Crons** and click **New monitor**. The form previews the next runs in the monitor's timezone as you type the schedule. Until the first check-in arrives, the monitor's page shows the snippets below with your DSN filled in. ## Check in without an SDK Any job that can run `curl` can check in. The URL carries the project ID, the slug and the DSN's public key: ```bash RUSTRAK_CRONS="https://rustrak.example.com/api//cron///" curl "$RUSTRAK_CRONS?status=in_progress" ./run-backup.sh && curl "$RUSTRAK_CRONS?status=ok" || curl "$RUSTRAK_CRONS?status=error" ``` `GET` and `POST` both work. The query string takes `status` (required), `check_in_id`, `environment` and `duration` in seconds. To create the monitor from a check-in, `POST` a JSON body with a `monitor_config` and `Content-Type: application/json`. Without that header the body is ignored: ```bash curl -X POST "$RUSTRAK_CRONS" \ -H "Content-Type: application/json" \ -d '{"status": "ok", "monitor_config": {"schedule": {"type": "crontab", "value": "0 3 * * *"}}}' ``` The endpoint answers `202 Accepted`. A check-in for a monitor that does not exist, sent without a `monitor_config`, gets a `404` saying so. If you pass your own `check_in_id`, use a random UUID (version 4), as the SDKs do. `sentry-cli monitors run` wraps a command and reports its exit code: ```bash export SENTRY_DSN=https://@rustrak.example.com/ sentry-cli monitors run --schedule "0 3 * * *" nightly-backup -- ./run-backup.sh ``` ## Settings | Setting | Default | Meaning | |---|---|---| | Schedule | | A five-field crontab (`0 3 * * *`), an alias such as `@daily`, or an interval (every 2 hours) | | Timezone | `UTC` | The zone the crontab is read in, so `0 9 * * *` stays at 09:00 across daylight saving time | | Grace period | 1 minute | How late a check-in may arrive before the run counts as missed. At most 28 days | | Max runtime | 30 minutes | How long a run may stay in progress before it times out. At most 7 days | | Failure tolerance | 1 | Consecutive failed check-ins before an issue opens, 1 to 720 | | Recovery tolerance | 1 | Consecutive successful check-ins before it resolves, 1 to 720 | A config sent from code overwrites only the fields it includes. The rest keep what was set in the dashboard. ## Mute a monitor Click **Mute** on the monitor's page. A muted monitor keeps recording check-ins and its status, but opens no issue and sends no alert. Use it during maintenance windows; **Unmute** turns it back on. Check-ins are kept for 90 days. --- # AI Agents If your app calls an LLM or runs an AI agent, **Agents** shows each run: the model calls and tool calls inside it, how many tokens they used, how long they took and which failed. It reads the same data as Sentry's AI Agent Monitoring, so there is nothing to install on the server. ## Send AI data Turn on your Sentry SDK's AI integration and tracing. With the Vercel AI SDK in Node.js: ```javascript import * as Sentry from '@sentry/node'; Sentry.init({ dsn: 'YOUR_DSN', tracesSampleRate: 1.0, integrations: [Sentry.vercelAIIntegration()], }); ``` ```javascript import { generateText } from 'ai'; import { openai } from '@ai-sdk/openai'; await Sentry.startSpan({ name: 'research_agent', op: 'gen_ai.invoke_agent' }, () => generateText({ model: openai('gpt-4o'), tools: { webSearch /* your tools */ }, prompt: 'Find the latest Rust release' }), ); ``` The integration records every model call and tool call. Wrap a run in a `gen_ai.invoke_agent` span to name the agent. Other SDKs have their own AI integrations, such as Python's for OpenAI, Anthropic and LangChain; any of them works. Rustrak treats a span as AI when its `op` starts with `gen_ai.` or `ai.`, or when it carries `gen_ai.operation.name`. AI spans travel inside your [performance](https://rustrak.dev/usage/performance) data, so tracing must be on. ## Overview The **Overview** tab sums up the window: agent runs, LLM calls, tool calls, tokens, average and p95 duration, and errors as a share of AI spans. Below it, runs and duration over time, then two tables: - **Models**: requests, errors, input and output tokens, average and p95 latency per model - **Tools**: calls, errors, average and p95 latency per tool Click a row in either table to see the runs that used that model or tool. Narrow everything with the environment filter, the window (last hour to 30 days, or all time) and the search bar, which takes `agent:`, `model:`, `tool:` and `status:`. There is no cost widget on purpose: model prices change too often to estimate, so Rustrak shows exact token counts. ## Traces The **Traces** tab lists one row per run: the agents involved, duration, LLM calls, tool calls, tokens, errors and start time. A run with no agent name shows as **(unnamed agent)**. Open a run to see its spans as a **Waterfall**, or switch to **Agents** to see which agents ran and handed off to each other. Click a span for its agent, model, tool, token counts (input, cached input, output, total), the messages sent and received, the system instructions, the tools offered and requested, and every attribute. When an LLM call has no response model, Rustrak uses the requested model. ## Conversations The **Conversations** tab groups runs that share a `gen_ai.conversation.id`, so you can read a chat turn by turn. Set that attribute on your agent spans to fill it. ## Turn agents off A project that does not use AI can drop this data: **Settings → Data → Products** turns Agents off. Rustrak then stops storing AI spans as they arrive; what is already stored stays. Turning Performance off turns Agents off too. ## API and storage Every widget has an endpoint under `/api/projects/{id}/agents/`: `summary`, `runs`, `duration`, `models`, `models/calls`, `models/tokens`, `tools`, `tools/stats`, `traces`, `conversations` and `conversations/{id}`. All but `conversations/{id}`, which returns the whole conversation, take `period_hours`, and the time series also take `interval_hours`. The [MCP server](https://rustrak.dev/sdks/mcp) reads the same data. AI spans count toward [storage](https://rustrak.dev/usage/storage). Spans sent inside a transaction are removed with it by retention cleanup; spans sent on their own are not removed by cleanup yet. --- # Alerts An alert tells your team when something happens in a project: a new issue, a regression, a user's feedback. It has two parts: - **Channels** say where to send: Slack, email, a webhook or a custom webhook. They are shared by every project and live in **Settings → Integrations**. - **Alerts** say when to send and to which channels. Each project has its own, under **Alerts** in its sidebar. ## Add a channel Go to **Settings → Integrations**, pick a provider and fill in its form. Each channel's **⋯** menu has **Test**, which sends a sample alert, so you can check it before any alert uses it. ### Slack Two ways to connect: - **Incoming webhook**: create one in Slack ([Incoming Webhooks](https://api.slack.com/messaging/webhooks)) and paste its `https://hooks.slack.com/...` URL. Messages go to the channel the webhook was made for. - **Bot token**: paste a token starting with `xoxb-`. Each alert then names the Slack channel to post in (`#alerts` or an ID like `C1234567890`), so one bot serves many channels. ### Email Give the SMTP host, port, username, password and sender address. Port `465` uses TLS from the start; any other port uses STARTTLS. Recipients are chosen on each alert, not on the channel. For Gmail, use an [app password](https://support.google.com/accounts/answer/185833). The server can also take SMTP defaults from the environment: `SMTP_HOST`, `SMTP_PORT` (default `587`), `SMTP_USERNAME`, `SMTP_PASSWORD` and `SMTP_FROM`. ### Webhook Rustrak POSTs a JSON payload to your URL. The URL is optional on the channel: leave it blank and each alert supplies its own. Add a secret to sign requests. ```json { "alert_id": "1-550e8400-1706140800000", "alert_type": "new_issue", "triggered_at": "2026-01-25T12:00:00.000Z", "project": { "id": 1, "name": "Storefront", "slug": "storefront" }, "issue": { "id": "550e8400-e29b-41d4-a716-446655440000", "short_id": "STOREFRONT-42", "title": "TypeError: Cannot read properties of undefined (reading 'price')", "level": "error", "first_seen": "2026-01-25T11:55:00Z", "last_seen": "2026-01-25T12:00:00Z", "event_count": 5 }, "issue_url": "https://rustrak.example.com/projects/1/issues/550e8400-e29b-41d4-a716-446655440000", "actor": "Rustrak" } ``` `alert_type` is `new_issue`, `regression`, `unmute` or `new_feedback`. `issue.level` can be absent. Every request carries these headers: | Header | Value | |---|---| | `Content-Type` | `application/json` | | `X-Rustrak-Timestamp` | Unix time in seconds | | `X-Rustrak-Request-ID` | The `alert_id` | | `X-Rustrak-Signature` | `sha256=`, only when the channel has a secret | The signature is an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the secret. Verify it against the body exactly as received: ```javascript const crypto = require('crypto'); function verify(rawBody, timestamp, signature, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` Reject requests whose timestamp is too old. That bounds how long a captured request stays usable but does not stop a repeat inside the window, so also skip any `X-Rustrak-Request-ID` you have already processed on that endpoint. Every rule and channel that reports the same event sends the same ID, so keep a separate record per channel. ### Custom webhook POSTs a JSON body you write yourself, for an endpoint that expects its own shape: a chat bot, a ticketing system. Write the body under **Message body**: **Start from** loads a known shape (Slack and compatible, Discord, Teams, WeCom, DingTalk, Feishu), **Insert field** adds an alert value at the cursor, and the preview below shows what will be sent. ```json { "text": "Rustrak: {{ issue.title }} ({{ issue.short_id }})\n{{ issue_url }}" } ``` Values are escaped for wherever you put them, and the body must render to valid JSON, which is checked when you save. The fields, rules and examples are on [Message body template](https://rustrak.dev/usage/alerts/message-body-template). It signs requests the same way as the webhook. A delivery counts as sent when the endpoint answers 2xx with a readable body; the body is never read for meaning. A 2xx whose body is over 64 KiB, cut off or not UTF-8 counts as failed. Some services answer `200` while refusing the message, so press **Test** after setting one up: it shows exactly what the endpoint answered. A WeCom or DingTalk bot with a custom keyword refuses any message without that keyword, so put the keyword in the body's fixed text. ### Extra headers Webhook and custom webhook channels can send extra headers, such as an `Authorization` your endpoint asks for. Up to 20; each name once, whatever its case. `Content-Type`, `Content-Length`, `Host`, `Transfer-Encoding` and anything starting with `X-Rustrak-` are reserved. ## Create an alert Open a project, go to **Alerts** and click **New alert**: | Field | What it does | |---|---| | **When** | **New issue**, **Regression** (a resolved issue comes back), **Reactivated** or **New feedback** | | **Environments** | Only events from these. **All** by default | | **Level** | Only events at this level or higher. An event with no level counts as `error` | | **After this many events** | New issues only: wait until the issue has this many events, then alert once | | **Notify** | One or more channels. Email asks for recipients, a Slack bot for a channel, and a webhook can take its own URL here | | **At most** | How often this alert can notify about the same issue: every time, every 5 minutes, once an hour, once a day, or any number of minutes | The summary on the right reads the alert back in a sentence before you save. **Send a test** sends a sample through the chosen channels. **Reactivated** is meant for an archived issue that starts happening again, but Rustrak never takes an issue out of the archive on its own yet, so this alert does not fire today. After this many events: `10` together with **production** keeps a one-off error on someone's laptop from paging anyone. ## Manage alerts Each alert has its own page with what it does in one sentence, an **Active** switch to pause it without deleting it, **Edit** and **Delete**. The **Deliveries** tab lists every alert sent, with the reason when one failed or was skipped. --- # Message body template The Custom Webhook integration sends a request body that you write. This page is the reference for that field: what you can put in it, what the editor does for you, and what the server checks before it lets you save. The short version: write the JSON your endpoint expects, and where a value should come from the alert, insert a field with `{{ … }}`. ```json { "text": "Rustrak: {{ issue.title }} ({{ issue.short_id }})\n{{ issue_url }}" } ``` That is the whole contract. A title with a quote in it, a newline, a Chinese character: the value is escaped for wherever you put it, so the body is always valid JSON. You never write an escaping filter. ## The editor The body field is a small JSON editor rather than a plain text box. | What | How it works | |---|---| | **Start from** | Drops in the shape a known service expects: one line of text and a link, Slack-compatible, Discord, Microsoft Teams, WeCom (text or markdown), DingTalk, Feishu, or a typed body for your own API. Replacing a body you had written shows an **Undo** toast. | | **Insert field** | Puts a field at the caret. Typing `{{` offers the same list inline, with a one-line description and the sample value of each field. | | **Pills** | An inserted field is drawn as one object: it is deleted in one keystroke and never edited from inside. A pill turns red when it names a field the alert does not have. | | **Preview** | Under the editor, the body rendered against a sample alert, byte for byte what a delivery would send. It updates a moment after you stop typing. | | **Errors** | A body that would not render is underlined where it breaks, with the reason on hover, in the gutter, and under the preview. Nothing is saved while it is broken. | | **Format** (the wand) | Re-indents the body the way you would write JSON by hand. It leaves a half-written body alone rather than mangling it. | Errors and the preview come from the same renderer that sends your alerts, so if the editor is quiet, the body renders. ## Fields The context is the alert payload. Every field below is available: | Field | Type | What it holds | |---|---|---| | `issue.title` | string | The error title | | `issue.short_id` | string | Human-readable issue id, e.g. `CHECKOUT-API-14` | | `issue.level` | string or null | `error`, `warning`, `info`, or absent | | `issue.event_count` | number | How many times it has happened | | `issue_url` | string | Link to the issue in Rustrak | | `issue.id` | string | Internal issue id | | `issue.first_seen` | timestamp | When it was first seen | | `issue.last_seen` | timestamp | When it was last seen | | `project.name` | string | The project name | | `project.slug` | string | The project slug | | `project.id` | number | Internal project id | | `alert_type` | string | What triggered the alert | | `alert_id` | string | Id of this delivery | | `triggered_at` | timestamp | When the alert fired | | `actor` | string | Who sent it, always `Rustrak` | A field that is not in this list is an error at save time, in words, rather than a blank in a message that goes out. ## How a field is written Where a field sits decides how its value is written, and the server decides that by reading the JSON around it. **Inside a string**, the value becomes text. Quotes and newlines in it are escaped; a number is written as digits; an absent value is written as nothing. ```json { "text": "Level: {{ issue.level }} · {{ issue.event_count }} events" } ``` renders as `"Level: error · 12 events"`, or `"Level: · 12 events"` when the issue has no level. **As a value of its own**, the field becomes a JSON value with its type kept: a string is quoted, a number stays a number, an absent value is `null`. ```json { "severity": {{ issue.level }}, "occurrences": {{ issue.event_count }} } ``` renders as `{ "severity": "error", "occurrences": 12 }`. ## Expressions The template language is [Minijinja](https://docs.rs/minijinja), a Jinja2 dialect, and the escaping above applies to any expression, not only a bare field. A message rarely needs more than a field, but when it does: ```json { "text": "{{ issue.title | upper }}" } { "text": "{{ 'New' if issue.event_count == 1 else 'Recurring' }}: {{ issue.title }}" } { "level": {{ issue.level or "unknown" }} } ``` An expression that already ends in `| tojson` is left exactly as written, so a body from before this behaviour keeps meaning what it said. ## Best practices **Put `issue_url` in the message.** A notification that says something broke without saying where costs someone a search. **Keep it short.** Chat clients truncate, and the detail is one click away behind `issue_url`. **Read the preview once with the fields in mind.** The sample alert has a level; a real one may not. `"Level: {{ issue.level }}"` reads fine either way, `{{ issue.level or "unknown" }}` reads better. **Press Test after saving.** The result shows the response your endpoint actually returned, which is the only way to see a service that answers `200` while refusing the message. ## Limits A template runs on the notification worker once per delivery, so it is bounded: | Limit | Value | What happens past it | |---|---|---| | Rendered body | 1 MiB | The delivery fails with that reason; nothing is sent | | Render budget | 500,000 instructions | Same. A template that wants more is not formatting an alert | | Response body read | 64 KiB | The delivery is recorded as failed rather than buffering it | Saving is checked too: the body is rendered against a sample payload, once with every field populated and once with `issue.level` absent. If either render fails, the integration is not saved, so a broken template cannot sit waiting for a real incident. ## Examples These are the **Start from** presets. Any endpoint that accepts JSON works the same way. **Slack-compatible incoming webhook** (Slack, Mattermost, Rocket.Chat) ```json { "text": "*{{ issue.title }}* ({{ issue.short_id }})\n{{ issue_url }}" } ``` **Discord** ```json { "content": "**{{ issue.title }}** ({{ issue.short_id }})\n{{ issue_url }}" } ``` **Microsoft Teams** (a Workflows webhook expecting an Adaptive Card) ```json { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "content": { "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "TextBlock", "text": "{{ issue.title }}", "weight": "Bolder", "size": "Medium", "wrap": true }, { "type": "TextBlock", "text": "{{ project.name }} · {{ issue.short_id }} · {{ issue.event_count }} events", "isSubtle": true, "wrap": true } ], "actions": [ { "type": "Action.OpenUrl", "title": "View issue", "url": "{{ issue_url }}" } ] } } ] } ``` **WeCom group bot** (企业微信) ```json { "msgtype": "text", "text": { "content": "Rustrak: {{ issue.title }} ({{ issue.short_id }})\n{{ issue_url }}" } } ``` The WeCom markdown preset uses `"msgtype": "markdown"`: ```json { "msgtype": "markdown", "markdown": { "content": "### {{ issue.title }}\n> Project: {{ project.name }}\n> Level: {{ issue.level }}\n> [View issue]({{ issue_url }})" } } ``` **DingTalk group bot** (钉钉) takes the same shape as WeCom text. Its `access_token` goes in the URL, so paste the full URL from the bot settings into **Webhook URL**. **Feishu / Lark group bot** (飞书) uses `msg_type`, not `msgtype`: ```json { "msg_type": "text", "content": { "text": "Rustrak: {{ issue.title }} ({{ issue.short_id }})\n{{ issue_url }}" } } ``` **A structured internal API**, with the types kept: ```json { "source": "rustrak", "event": {{ alert_type }}, "severity": {{ issue.level }}, "title": {{ issue.title }}, "url": {{ issue_url }}, "occurrences": {{ issue.event_count }}, "project": {{ project.slug }} } ``` ## Troubleshooting **"Rendered template is not valid JSON"**: the body rendered, but the result is not JSON. A missing comma, an unbalanced brace. The underline points at the character where parsing stopped. **"Invalid template syntax"**: the body is not a valid template at all: an unclosed `{{`, or a `{%` block with no end. **"undefined value"**: a field that is not in the list above, usually a typo. The editor draws it as a red pill before you save. **"Template is too complex to render"**: the render budget ran out, usually a loop over something large. Alert messages do not need one. **The alert says it was delivered but nothing arrived**: delivery is judged by the HTTP status, and some services answer `200` while refusing the message. Press **Test** and read what came back. ## Notes - The **Webhook URL** is optional here. An alert rule can supply its own, which is how one integration can post to several destinations. - A **signing secret** adds an `X-Rustrak-Signature` header, `sha256=` followed by the HMAC-SHA256 of `timestamp.body`, over the body bytes exactly as sent. Verify it the same way as the plain [Webhook](https://rustrak.dev/usage/alerts#webhook). --- # Source Maps Source maps turn a minified JavaScript stack trace back into your code: the real file, line, function name and source lines. Upload them when you build, and every error from that build shows readable frames. Without source maps: ```text Error: Invoice not found ? app:///_next/server/chunks/ssr/_0bwf~zv._.js:2:239718 ? app:///_next/server/chunks/ssr/apps_myapp_src_lib_actions_invoices_ts_09hq4k7._.js:2:5956 ``` With them: ```text Error: Invoice not found getPostedInvoice src/lib/actions/invoices.ts:84 fetchInvoiceData src/app/invoices/[id]/page.tsx:31 ``` Rustrak takes uploads from the same tools as Sentry: the bundler plugins and `sentry-cli`. Hermes and Metro maps from React Native work too. ## How a frame finds its map Sentry's tools give each built file and its map a shared **debug ID**. The SDK sends the debug IDs of the loaded files with every event (`debug_meta`), and Rustrak uses them to pick the right map for each frame. Matching is by debug ID only, so the files must go through Sentry's plugin or `sentry-cli sourcemaps inject` before you deploy them. ## Upload from your bundler Turn on source maps in the build and add Sentry's plugin, pointed at Rustrak. ```javascript // vite.config.js import { defineConfig } from 'vite'; import { sentryVitePlugin } from '@sentry/vite-plugin'; export default defineConfig({ build: { sourcemap: true }, plugins: [ sentryVitePlugin({ org: 'anything', project: 'storefront', authToken: process.env.SENTRY_AUTH_TOKEN, url: 'https://rustrak.example.com', }), ], }); ``` ```javascript // webpack.config.js const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); module.exports = { devtool: 'source-map', plugins: [ sentryWebpackPlugin({ org: 'anything', project: 'storefront', authToken: process.env.SENTRY_AUTH_TOKEN, url: 'https://rustrak.example.com', }), ], }; ``` Framework SDKs wrap these plugins and call the server option `sentryUrl` instead of `url`: `@sentry/nextjs`, `@sentry/sveltekit` and others. With `@sentry/sveltekit` v10 the options sit at the root of `sentrySvelteKit({ ... })`; the older `sourceMapsUploadOptions` object is deprecated. Uploads happen only in production builds (`vite build`), never in the dev server. ## Upload with sentry-cli For any other build, inject the debug IDs and upload the output folder: ```bash export SENTRY_URL=https://rustrak.example.com export SENTRY_AUTH_TOKEN= export SENTRY_ORG=anything export SENTRY_PROJECT=storefront sentry-cli sourcemaps inject ./dist sentry-cli sourcemaps upload ./dist ``` Deploy the injected files, not the ones from before `inject`. ## The options | Option | Env var | What Rustrak does with it | |---|---|---| | `url` (`sentryUrl`) | `SENTRY_URL` | Where the upload goes. Must be your Rustrak server | | `authToken` | `SENTRY_AUTH_TOKEN` | An [API token](https://rustrak.dev/usage/tokens) from **Settings → API Tokens** | | `project` | `SENTRY_PROJECT` | The project's slug or numeric ID. Must exist | | `org` | `SENTRY_ORG` | Ignored. Any non-empty value | Rustrak has no organizations, and the DSN carries none. The option exists because the tools check that an organization exists before uploading; Rustrak answers that check for any name. An explicit option wins over its environment variable. **The DSN does not decide where source maps go.** It sends events. Without `url`, the plugin uploads to `sentry.io`. Without `authToken`, it skips the upload and the build still succeeds. The upload runs during the build, so the token and URL must be there at build time. In Docker or CI, pass them to the build step: a variable set only on the running container comes too late. ## Storage Source maps are stored on disk under `/data/sourcemaps`, inside the `/data` volume from the [installation](https://rustrak.dev/getting-started/installation), so they survive restarts and upgrades. To keep them elsewhere, mount a volume and point `SOURCEMAP_STORAGE_PATH` at it. The directory must be writable by the server. ## Stack trace still minified? 1. **Does the build write `.map` files?** `build.sourcemap: true` in Vite, `devtool: 'source-map'` in webpack. 2. **Did the upload run?** The plugin logs it. No log at all usually means `authToken` is missing. 3. **Did it go to Rustrak?** Without `url`, it went to `sentry.io`. 4. **`404 project not found` at the end of the upload?** `project` does not match a Rustrak project. The files go up first and are attached to the project last, so this fails only at the end, and the build still passes. 5. **Are the deployed files the injected ones?** Each needs its `//# debugId=` comment. 6. **Is the error from a build after the upload?** Maps apply to events from files with those debug IDs. List what the server holds for a project: ```bash curl -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \ "$SENTRY_URL/api/0/projects/_/storefront/files/source-maps/" ``` Each entry shows a debug ID and whether it is a `minified_source` or a `source_map`. The server assembles an upload in the background, so a list taken right after the upload can be empty for a few seconds. --- # Debug Files Debug files let Rustrak turn the stack traces of compiled and obfuscated apps back into your code. They are to Android, JVM and iOS apps what [source maps](https://rustrak.dev/usage/source-maps) are to JavaScript. | Platform | What you upload | What Rustrak does with it | | --- | --- | --- | | Android, JVM | ProGuard / R8 mapping | Restores class, method and line names | | Android, JVM, Kotlin | JVM source bundle | Adds the source lines around each frame | | iOS, macOS (Apple) | dSYM | Resolves addresses to functions, files and lines, inlined calls included | | iOS, macOS (Apple) | Native source bundle | Adds the source lines around each frame | Every file is matched to events by its **debug ID**, which the SDK sends with each event (`debug_meta`). There is nothing to configure on the server. Rustrak only reads these formats. Android NDK libraries (ELF), Windows (PDB), Breakpad symbols and WebAssembly are not supported yet. ## Before and after An R8 build without its mapping: ```bash java.lang.IllegalStateException: empty cart at i.a.a(SourceFile:45) at i.b.a(SourceFile:3) at i.c.a(SourceFile:15) at com.example.shop.MainActivity.onCreate(SourceFile:17) ``` With the mapping and a source bundle: ```bash java.lang.IllegalStateException: empty cart at com.example.shop.Cart.total(Checkout.kt:6) throw IllegalStateException("empty cart") at com.example.shop.Checkout.pay(Checkout.kt:13) at com.example.shop.CheckoutKt.runCheckout(Checkout.kt:16) at com.example.shop.MainActivity.onCreate(MainActivity.kt:11) ``` ## Pointing the upload at Rustrak Uploads go through the same tools as with Sentry: the Sentry Android Gradle plugin or `sentry-cli`. Point them at your server with the same four options the [source maps](https://rustrak.dev/usage/source-maps#the-options) page describes: | Option | Env var | Value | | --- | --- | --- | | `url` | `SENTRY_URL` | Your Rustrak server | | `authToken` | `SENTRY_AUTH_TOKEN` | A token from **Settings → API Tokens** | | `projectName` / `project` | `SENTRY_PROJECT` | The project's slug | | `org` | `SENTRY_ORG` | Anything; Rustrak ignores it | > **Without `url`, uploads go to sentry.io.** The DSN decides where events go, > not where debug files go. ## Android The [Sentry Android Gradle plugin](https://docs.sentry.io/platforms/android/configuration/gradle/) uploads the R8 mapping, and the sources when `includeSourceContext` is on, on every release build: ```kotlin // app/build.gradle.kts plugins { id("com.android.application") id("io.sentry.android.gradle") version "6.24.0" } android { buildTypes { release { isMinifyEnabled = true // the mapping only exists for minified builds } } } sentry { url.set("https://rustrak.example.com/") org.set("anything") projectName.set("my-app") authToken.set(System.getenv("SENTRY_AUTH_TOKEN")) includeSourceContext.set(true) } ``` `./gradlew assembleRelease` prints one `UPLOADED` line per file: ```bash UPLOADED a07609d2-… (Proguard mapping) UPLOADED 855f6493-… (855f6493-….zip; sources) ``` The plugin also writes their IDs into the APK, so the SDK knows which mapping and bundle each event belongs to. ## JVM Outside Android, run ProGuard or R8 yourself and upload with `sentry-cli`: ```bash # the mapping, and the properties file the SDK reads to find it sentry-cli upload-proguard --write-properties build/sentry-debug-meta.properties mapping.txt # the sources, under a debug ID you choose sentry-cli debug-files bundle-jvm --output build/bundle --debug-id "$BUNDLE_ID" src/main/kotlin sentry-cli debug-files upload --type jvm build/bundle echo "io.sentry.bundle-ids=$BUNDLE_ID" >> build/sentry-debug-meta.properties ``` Put `sentry-debug-meta.properties` at the root of the obfuscated jar. The Java SDK reads it from the classpath. **Point `bundle-jvm` at the source root**, the folder where package paths start (`src/main/kotlin`), not at the project. Frames are looked up as `/`, so a bundle made one level up never matches. ## iOS and macOS Upload each dSYM with its sources: ```bash sentry-cli debug-files upload --include-sources path/to/MyApp.app.dSYM ``` In Xcode, run it from a **Run Script** build phase so every archive uploads its own dSYMs: ```bash export SENTRY_URL=https://rustrak.example.com/ export SENTRY_ORG=anything export SENTRY_PROJECT=my-app # SENTRY_AUTH_TOKEN comes from the build environment, never from the project sentry-cli debug-files upload --include-sources "$DWARF_DSYM_FOLDER_PATH" ``` Uploading the app's executable as well is harmless: when its dSYM is already there, Rustrak keeps the dSYM, which has line information. ## Kotlin Multiplatform A KMP app is the two cases above in one project: the Android target uploads with the Android Gradle plugin, and the iOS target uploads its dSYM with `sentry-cli`. Crashes in `commonMain` come back with their source lines on both. ```kotlin // build.gradle.kts plugins { kotlin("multiplatform") id("com.android.application") id("io.sentry.kotlin.multiplatform.gradle") version "0.29.0" // adds the SDK to commonMain id("io.sentry.android.gradle") version "6.24.0" // uploads the Android files } sentry { autoInstallation { enabled.set(false) } // the KMP plugin already adds the SDK includeSourceContext.set(true) additionalSourceDirsForSourceContext.set(setOf("src/commonMain/kotlin")) url.set("https://rustrak.example.com/") org.set("anything") projectName.set("my-app") authToken.set(System.getenv("SENTRY_AUTH_TOKEN")) } ``` **`additionalSourceDirsForSourceContext` is what gives `commonMain` frames their source.** The Android plugin only bundles the Android source sets unless you list the shared ones. Three things to know about the KMP SDK (0.29): - **Initialise it from code on Android.** The KMP SDK turns off the Android SDK's automatic start, so a DSN in `AndroidManifest.xml` alone sends nothing. Call `Sentry.init { … }` from your `Application` or first `Activity`. - **Set `release` yourself.** On Android the KMP SDK passes its own `release` to the Android SDK even when it is unset, so events arrive with no release instead of the usual `com.example.app@1.2+3`. Set it in `Sentry.init`; `dist` (the version code) is still filled in. - **Sentry Cocoa is not installed for you below Kotlin 2.4.** Add it with Swift Package Manager in Xcode, or tell the plugin where it is with `sentryKmp { linker { frameworkPath.set("path/to/Sentry.xcframework") } }`. ```kotlin // commonMain Sentry.init { options -> options.dsn = "https://@rustrak.example.com/" options.release = "com.example.app@1.2+3" } ``` Each target's `release` and `dist` show in the event's highlights, so an Android crash and an iOS crash of the same version can be told apart. ## Troubleshooting: the stack trace is still obfuscated **1. Did the build print `UPLOADED` lines?** No output usually means a missing `authToken`. `404 project not found` means `projectName` is not a project slug. **2. Was the file uploaded before the crash was processed?** Rustrak reads debug files when it processes an event and does not go back to it later. Android crashes are often sent the moment they happen, so a crash that arrives while the upload is still being processed keeps its raw frames. Upload as part of the build, before the app is installed. **3. What does the event say?** Open the event's JSON: - an `errors` entry with `proguard_missing_mapping` names the mapping UUID the app asked for and Rustrak does not have. Compare it with the `UPLOADED` line. - an image under `debug_meta.images` with `"debug_status": "missing"` is a binary whose dSYM was never uploaded. Its `debug_id` is the dSYM's UUID (`dwarfdump --uuid MyApp.app.dSYM`). **4. Frames are named but have no source lines?** The source bundle is missing or was built from the wrong folder: see `additionalSourceDirsForSourceContext` for Kotlin Multiplatform, and the source root note for JVM. --- # Team & Access One Rustrak serves a whole team. Each person has their own login, and their access decides which projects they see and what they can do there. ## Owners and members Every account is an **Owner** or a **Member**. An owner can do everything, on every project, and roles do not apply to them. A member starts with nothing: no projects, no settings, not even an API token. What a member can do comes from the [roles](https://rustrak.dev/usage/roles) you give them. The first account, created by `CREATE_SUPERUSER`, is the primary owner. It cannot be demoted or removed, so the instance can never be locked out. Only an owner can make someone an owner or change one. The API calls an owner `admin`, in `POST /api/invitations` and `PATCH /api/team/{id}/role`. So does [`OIDC_ADMIN_GROUPS`](https://rustrak.dev/configuration/sso#roles-from-provider-groups). ## Invite someone There is no open sign-up. Inviting takes the **Invitations** permission with **Create**, which owners always hold. 1. Go to **Settings → Members** and click **Invite**. 2. Enter the email and pick a **Role**: one of your roles, **No access**, or **Owner** if you are one. 3. If the role reaches projects, pick its **Scope**: **All projects** or one project. 4. Turn on **Send the invite by email** if you want Rustrak to send it. 5. Click **Invite**. Without the email, the invitation link is copied to your clipboard: share it yourself. You can only offer what you hold yourself: a role beyond your own permissions is refused. **Pending invitations** lets you copy the link again, resend the email or revoke the invitation. A link works once and expires after 48 hours. The invitee opens the link, sets a password and is signed in, with the role from the invitation. With **No access** they see an empty project list until someone gives them a role. With [single sign-on](https://rustrak.dev/configuration/sso), people can also sign in through your identity provider. ### Invitation emails They go out through the same SMTP as alert emails: the first enabled email integration in **Settings → Integrations**, with the `SMTP_*` [environment variables](https://rustrak.dev/configuration/environment) filling in whatever it leaves empty. Either one alone is enough. The link uses `DASHBOARD_URL`, else `PUBLIC_URL`; without an SMTP host and one of those, the switch stays off. If the email fails, the invitation is still created and its link copied, so you can share it yourself or resend it later. ## Give someone access In **Settings → Members**, open the person. **Access** has one line per role, each with its **Scope**. 1. Click **Add access** and pick a **Role**. 2. Pick the **Scope**: **All projects**, which includes projects created later, or the projects you choose. 3. Click **Save**. A role with only workspace permissions shows **Entire workspace** as its scope. A person can hold several lines, and they add up. You cannot change your own access. For anyone else you need the **Users** permission with **Edit**, and you must hold every role you give or take away, in the scope it applies to. Otherwise Rustrak answers `You cannot grant permissions you do not hold`. ### Custom permissions When no role fits one person, pick **Custom permissions** as the **Role**, or click **Customize** on a line to start from that role's permissions. Tick what they need in the grid below, set the **Scope** and click **Save**. These permissions belong to that one person. They do not appear under **Settings → Roles** and go away with the account. A person has at most one set of custom permissions. ## Manage the team In **Settings → Members**, the **Actions** menu next to each person has **Change role**, for owners only, and **Remove**. Making a member an owner keeps their roles but ignores them. Making them a member again brings those roles back. **Remove** deletes the account, so its access and API tokens go with it. You cannot remove yourself, the primary owner or the last owner. ## Project members Open a project's **Settings → Members** to see who holds a role on it, including roles on all projects. Owners are not listed: they reach every project anyway. Viewing the list takes **Project members** with **View**; changing it takes **Edit**. **Add Member** gives someone a role on this project alone. The **Actions** menu changes that role or removes it. Removing only takes away the roles on this project: someone with a role on all projects keeps it, and still sees the project. A project needs no admin of its own, and creating one does not make you its admin. Your own roles extend to the new project instead. See [Who can create a project](https://rustrak.dev/usage/roles#who-can-create-a-project). ## API [API tokens](https://rustrak.dev/usage/tokens) act as the user who made them, up to the limits set on the token. ```bash # The team curl -H "Authorization: Bearer $RUSTRAK_TOKEN" "$RUSTRAK_URL/api/team" # Invite someone with role 3 on project 1; the response holds the invitation token curl -X POST -H "Authorization: Bearer $RUSTRAK_TOKEN" -H "Content-Type: application/json" \ -d '{"email": "lena@example.com", "role": "member", "role_id": 3, "project_id": 1}' \ "$RUSTRAK_URL/api/invitations" # Same, and email the invite curl -X POST -H "Authorization: Bearer $RUSTRAK_TOKEN" -H "Content-Type: application/json" \ -d '{"email": "lena@example.com", "role": "member", "role_id": 3, "project_id": 1, "send_email": true}' \ "$RUSTRAK_URL/api/invitations" # Email the invite link again curl -X POST -H "Authorization: Bearer $RUSTRAK_TOKEN" "$RUSTRAK_URL/api/invitations/ID/resend" # Replace everything user 2 holds: role 2 on project 1, role 3 on every project curl -X PUT -H "Authorization: Bearer $RUSTRAK_TOKEN" -H "Content-Type: application/json" \ -d '{"assignments": [{"role_id": 2, "project_id": 1}, {"role_id": 3, "project_id": null}]}' \ "$RUSTRAK_URL/api/users/2/assignments" # Set user 2's roles on project 1 only; an empty list removes them curl -X PUT -H "Authorization: Bearer $RUSTRAK_TOKEN" -H "Content-Type: application/json" \ -d '{"user_id": 2, "role_ids": [2]}' "$RUSTRAK_URL/api/projects/1/members" ``` `PUT /api/users/{id}/assignments` replaces everything, custom permissions included: leave `personal` out and they are gone. | Situation | Answer | |---|---| | Someone opens a project they have no permission on | `404`, so the project's existence does not leak | | Someone sees a project but lacks the permission, like a Viewer resolving an issue | `403`, `Missing permission issues:update` | | Giving a role or permission you do not hold | `403`, `You cannot grant permissions you do not hold` | | Changing your own access | `403` | | Inviting or changing an owner when you are not one | `403` | | `POST /auth/register` | `403`: sign-up is by invitation only | | `send_email: true` with no SMTP host, or no `DASHBOARD_URL` or `PUBLIC_URL` | `400`, naming `send_email` | | Resending an expired or accepted invitation | `400` | | Demoting or removing the last owner | `409` | --- # Roles & Permissions A role is a named set of permissions you give to [members](https://rustrak.dev/usage/team), on all projects or on the ones you choose. Owners do not need roles: they can already do everything. A new install has no roles. Create the first one before you invite anyone who is not an owner, or they arrive with **No access**. An upgraded install starts with the roles its project members already used: see [Upgrade to 0.25](https://rustrak.dev/upgrading/0-25). ## Create a role 1. Go to **Settings → Roles** and click **New role**. 2. Enter a **Name**. To start from an existing role, pick it under **Copy permissions from**. 3. Click **Create role**. Rustrak opens the new role. 4. Go through the tabs (**Workspace**, **Settings**, **Issues**, **Monitoring**, **Releases**, **Alerts**, **Project**) and tick what the role allows. Each tab counts what is on. 5. Under **General**, add a **Description**. It shows wherever someone assigns the role. 6. Click **Save**. The grid only offers permissions you hold. Then [give the role to someone](https://rustrak.dev/usage/team#give-someone-access). Saving a change reaches everyone holding the role at once, along with their API tokens. **Delete role** only works on a role nobody holds and no pending invitation names; otherwise Rustrak answers `409`. ## How permissions add up Each permission is a resource and an action, like **Issues and events** with **Edit**. Someone's access is the sum of every role they hold. There are no deny rules, so a second role can only add. Where a permission applies depends on its kind: - **Project permissions** apply on the role's **Scope**: all projects, including ones created later, or the projects chosen. - **Workspace and settings permissions** have no project to apply to. They count as soon as the role is held, even if its scope is one project. A role with **Users** and **View** given on Storefront lets its holder list the whole team. Two rules save you some ticking. **Edit**, **Create** or **Delete** on a resource implies **View** on it, except for API tokens. And any permission on a project shows that project: there is no separate permission to see it. The dashboard follows the same permissions: settings pages and buttons that you cannot use are hidden or read-only. The server enforces them either way, for the dashboard, the API and the [MCP server](https://rustrak.dev/sdks/mcp). ## Who can change what Nobody hands out more than they hold. You can only write a permission into a role if you hold it, and in the case of a project permission, on every project. Editing an existing role also needs every permission it has now, so nobody strips one they could not give back. You cannot change your own roles. Only owners make or change owners. ## Who can create a project Creating a project takes **Projects** with **Create** and at least one project permission somewhere. With only the first, the server refuses with `Creating a project also requires a project permission`, because the creator could never see the result. Creating one does not make you its admin. If your roles reach only some projects, those roles and your custom permissions extend to the new project. A Viewer who creates a project is a Viewer on it. If your roles reach all projects, nothing needs extending. ## All permissions The API calls an action `read`, `create`, `update` or `delete`; the dashboard shows **View**, **Create**, **Edit** and **Delete**. A permission is written `resource:action`, like `issues:update`, in API errors and in role and token requests. ### Workspace | Dashboard name | Resource | Actions | Covers | |---|---|---|---| | Projects | `projects` | create | Creating projects | | Users | `users` | read, update, delete | Seeing members, changing their access, removing them | | Invitations | `invitations` | read, create, delete | Pending invitations, inviting, revoking | | Roles | `roles` | read, create, update, delete | **Settings → Roles** | | Other users' API tokens | `api-tokens` | read, create, delete | `read` and `delete`: everyone's tokens. `create`: making your own tokens | Everyone can see and revoke their own tokens without any of these. ### Settings | Dashboard name | Resource | Actions | Covers | |---|---|---|---| | Integrations (Slack, webhooks, email) | `integrations` | read, create, update, delete | Alert and invitation delivery channels | | GitHub app | `github-app` | read, update | Connecting [GitHub](https://rustrak.dev/configuration/github) | | Storage and retention | `storage` | read, update, delete | [Storage](https://rustrak.dev/usage/storage): usage, retention, deleting data | | AI assistants (MCP) | `mcp` | update | Turning the [MCP server](https://rustrak.dev/sdks/mcp) on and off. Connecting an assistant takes no permission | | Telemetry | `telemetry` | read | The [telemetry preview](https://rustrak.dev/configuration/telemetry) | ### Project | Dashboard name | Resource | Actions | Covers | |---|---|---|---| | Issues and events | `issues` | read, update, delete | Viewing, resolving, archiving, assigning, merging and deleting | | Performance and agents | `performance` | read | Transactions, spans, endpoints, AI agent traces | | Logs | `logs` | read | The log stream | | Release health | `sessions` | read | Crash-free sessions and users | | Releases | `releases` | read, create, update | Releases, their commits and deploys | | Source maps and debug files | `source-maps` | read, create | Reading and uploading them | | Alert rules | `alert-rules` | read, create, update, delete | Rules on the project | | Cron monitors | `monitors` | read, create, update, delete | Monitors and their check-ins | | Project settings | `project` | update, delete | Name, platform, data and products; deleting the project | | SDK keys (DSN) | `client-keys` | read | Seeing the DSN. Without it, `sentry_key` and `dsn` come back empty | | Project members | `project-members` | read, update | The project's **Members** page | | Repository link | `project-github` | read, update | The project's **Repository** page | `GET /api/access/catalog` returns this list, and `GET /api/access/me` what the caller holds. --- # API Tokens An API token lets a script, a CI job or another tool use Rustrak's API: `sentry-cli` uploading [source maps](https://rustrak.dev/usage/source-maps), a deploy creating a [release](https://rustrak.dev/usage/releases), an AI assistant through the [MCP server](https://rustrak.dev/sdks/mcp). It is not the DSN. The DSN only sends events; a token reads data and changes things. ## Create a token 1. Go to **Settings → API Tokens** and click **New Token**. 2. Give it a **Description** that says where it is used ("CI deploy"). 3. Limit it, if you want: - **Permissions**: **All my permissions**, or **Custom permissions** and tick what it needs. - **Projects**: **All authorized projects**, or the ones you choose. - **Expiration date**: the token stops working at the end of that day. Leave it blank and it never expires. 4. Click **Create Token** and copy the token from the dialog. **Copy it now.** Rustrak stores only a SHA-256 hash, so no one can show you the token again. If you lose it, create another. Creating a token takes `api-tokens:create`, which owners always hold. For a member it comes from a role: the row is called **Other users' API tokens**, under **Workspace**, column **Create**. Without it, **New Token** does not appear. You can only create tokens from a signed-in session. A token cannot create another token, or a limited one could mint itself a way out of its limits. The server answers `403`, `API tokens can only be created from a signed-in session`. ## What a token can do A token acts as the user who created it, narrowed by its limits. It never does more than that user can right now: take a role away from them and their tokens lose it too. Removing the user deletes their tokens. Give each job only what it needs: | Job | Permissions | |---|---| | Upload source maps or debug files | `source-maps:create` | | Create and update releases | `releases:create`, `releases:update` | | Read issues from a script | `issues:read` | A token limited to one project gets `404` on the others, as if they did not exist. The list of projects stays fixed: a project created later is only reached by a token on **All authorized projects**. ## Use it Send it in the `Authorization` header: ```bash curl -H "Authorization: Bearer $RUSTRAK_TOKEN" "$RUSTRAK_URL/api/projects" ``` Tools that speak Sentry's API, like `sentry-cli` and the bundler plugins, take it as `SENTRY_AUTH_TOKEN`. The [API reference](https://rustrak.dev/api-reference) lists every endpoint. To create one over the API, sign in first: this needs the session cookie, not a token. ```bash curl -X POST -b cookies.txt -H "Content-Type: application/json" \ -d '{"description": "CI deploy", "permissions": [{"resource": "source-maps", "action": "create"}], "project_ids": [1], "expires_at": "2027-01-31T23:59:59Z"}' \ "$RUSTRAK_URL/api/tokens" ``` Leave out `permissions` or `project_ids` for everything its owner has, and `expires_at` for a token that never expires. Only this response holds the `token` field. ## Manage tokens **Settings → API Tokens** lists your tokens with their first 8 characters, what they are limited to and when each was created and last used. Delete one to revoke it at once. To rotate a token, create the new one, update whatever uses it, then delete the old one. With `api-tokens:read` you see everyone's tokens, and with `api-tokens:delete` you can revoke them. Their values stay hidden from you too. | Answer | Why | |---|---| | `401` | The header is missing or malformed, or the token was deleted or has expired. A token is 40 hexadecimal characters | | `403`, `Missing permission source-maps:create` | The token or its owner lacks that permission | | `404` on a project | The token or its owner does not reach that project | --- # Storage Every error, transaction, log and source map takes disk space. **Settings → Storage** shows what the instance holds, sets how long each kind of data is kept, and deletes old data. Seeing it takes the **Storage and retention** permission, so by default only owners do: every cleanup deletes for good. ## Usage - **Storage used**: the total, one bar split by kind (errors, transactions, logs, source maps) with what each weighs and how many rows or files it is, and a line saying when the next cleanup runs and roughly how much it frees. - **Age of the data**: bytes by how long ago they arrived (under 7 days, 7–14, 14–30, 30–90, 90–180, 180 days to a year, over a year), stacked by kind. What the next cleanup deletes is hatched. - **Projects**: what each one holds, largest first, its size per kind, and whether it uses the general retention or its own. Sizes of errors, transactions and logs are the length of the payloads Rustrak holds, estimated from a sample of each project's rows and scaled by how many it has, so the page stays fast however much data there is: the newest rows for errors, transactions and logs, and for spans a random sample of the project's transactions. Source-map sizes are exact, summed from the stored file sizes. Row counts are exact, except spans, which are estimated from that sample. Ages are keyed on **ingestion time** (when Rustrak received the data), not on the client-supplied timestamp, like retention itself. ## Retention - **Configure retention**, at the top of the page, sets how long each kind is kept in every project (7, 14, 30, 90, 180 or 365 days, or **Forever**) and when the cleanup runs: every day or every week, at an hour, in UTC. Everything is kept forever until you choose otherwise, and the cleanup only runs while some kind has a limit. - **Change**, on a project's row, gives that project its own periods, or puts it back on the general ones. Both say what the next cleanup would delete with your changes before you save. **Save** applies them at the next cleanup; **Save and clean up now** asks once more, with the size, and deletes in the background straight away. ### Deleting now **Delete data now**, beside **Configure retention**, deletes without waiting for the cleanup: pick the kinds (errors, transactions, logs), how old (older than 1 to 365 days) and where (every project or one). The dialog counts what goes as you choose, and the button says how much. It runs in the background like the cleanup, and only one of them runs at a time. It can also delete the source maps no upload uses. If the server restarts while it runs, what it already deleted stays deleted, but nothing picks the rest up: run it again (the cleanup, by contrast, finishes the rest on its next run). What a cleanup does: - Deletes **events**, **transactions** (their **spans** go with them) and **logs** older than each project's period for that kind. - Removes any **issue** left with zero events, and keeps the event counts on projects and issues in sync. - Deletes the **source maps no upload references** any more, for example the ones a deleted project left behind. Referenced files are never touched. - Works in small batches, each committed on its own, so ingestion keeps working. If the server restarts mid-cleanup, what was already deleted stays deleted and the next run finishes the rest. If the server was down at run time, it runs once when it comes back. - On SQLite, gives the space it freed back to the disk once the deletes are done, a few megabytes at a time, so the database file shrinks. This needs a database Rustrak created with incremental auto-vacuum, which every new one is; an older file reuses the freed space for new data but keeps its size until it is compacted once, with the **Compact database** button on this page (see [Reclaiming disk space](https://rustrak.dev/configuration/database#reclaiming-disk-space)). Compaction needs free disk of about twice the data it keeps and blocks writes while it runs: errors that arrive meanwhile are kept and processed afterwards, but logs, traces and sessions are lost. Run it at a quiet time. > **Warning:** Deleted data is gone for good. Check what the panel says will go before you save. ## API access Every endpoint takes a [token](https://rustrak.dev/usage/tokens) or a session whose user holds the permission: `storage:read` to look, `storage:update` to change retention, `storage:delete` to delete. Anyone else gets `403 Forbidden`. ```bash # Instance-wide summary curl -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/summary # Per-project breakdown curl -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/projects # Dry-run a cleanup (deletes nothing) curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"older_than_days":30}' \ http://localhost:8080/api/storage/cleanup/preview # Start the cleanup (optionally scoped to one project). Answers 202 at once. curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"older_than_days":30,"project_id":1}' \ http://localhost:8080/api/storage/cleanup # Follow it until it is no longer "running" curl -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/cleanup/status # How long ago each project's data arrived, per kind curl -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/age # Enforce the saved retention now (202, then follow the cleanup status) curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/retention/run # Retention: read it, see what new settings would delete, save them. # A null period keeps that kind forever; an override replaces the defaults # for its project. curl -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/retention curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"defaults": {"events_days": 90, "transactions_days": 30, "logs_days": 14}, "overrides": [{"project_id": 1, "events_days": 365, "transactions_days": null, "logs_days": 7}], "frequency": "daily", "hour": 3, "weekday": 0}' \ http://localhost:8080/api/storage/retention/preview curl -X PUT -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"defaults": {"events_days": 90, "transactions_days": 30, "logs_days": 14}, "overrides": [], "frequency": "weekly", "hour": 4, "weekday": 0}' \ http://localhost:8080/api/storage/retention # Dry-run, then remove, orphaned source maps curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/source-maps/gc/preview curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/storage/source-maps/gc ``` The preview returns the counts a cleanup would remove: ```json { "events": 1240, "transactions": 80000, "spans": 1200000, "logs": 5000, "issues_removed": 37 } ``` Starting a cleanup and asking for its status both return the job. `state` is `idle` (none since the server started), `running`, `completed` or `failed`, and `removed` has the same shape as the preview: the rows deleted so far while it runs, the total once it has finished. ```json { "state": "completed", "removed": { "events": 1240, "transactions": 80000, "spans": 1200000, "logs": 5000, "issues_removed": 37 }, "started_at": "2026-10-07T10:00:00Z", "finished_at": "2026-10-07T10:04:12Z", "error": null } ``` You can also drive all of this from an AI assistant via the [MCP server](https://rustrak.dev/sdks/mcp): the cleanup tool, and saving retention that deletes anything, both require an explicit confirmation flag. ## Behavior reference | Situation | Result | |---|---| | Member opens **Settings → Storage** or calls a storage endpoint | `403 Forbidden` | | Preview (cleanup or source-map GC) | Counts only; nothing is deleted | | Start a cleanup | `202 Accepted`; it runs in the background | | Start a cleanup while one is running | `409 Conflict` | | Server restarts during a cleanup | Batches already deleted stay deleted; run it again for the rest | | Cleanup scoped to one project | Other projects' data is never touched | | Every kind kept forever, by default and in every override | The cleanup never runs | | Project with an override | Its own periods apply; the defaults don't | | Issue left with zero events after cleanup | Issue is deleted | | Source-map GC | Only unreferenced (orphaned) files are removed | | Deleting events | Project and issue event counts stay consistent | | Cleanup on SQLite | The freed space goes back to the disk (a database created before incremental auto-vacuum needs a one-off `VACUUM` first) | --- # Language & Region The dashboard speaks English, Spanish (Español), French (Français), Romanian (Română) and Simplified Chinese (简体中文). Language, time zone and theme are personal: each person picks their own in **Settings → Preferences**, and language and time zone are saved on their account, so they follow them to any browser. ## Language Pick one under **Language and region**. It applies at once. Until someone picks a language, the dashboard follows the one their browser asks for, and falls back to English when it has no translation. Regional variants match their language: any variant of Chinese gets Chinese. The login page and invitation links follow the browser too, since there is no account to read yet. ## Time zone The time zone decides how every date and time is shown. - The first time someone opens the dashboard, their browser's zone is saved to their account, but only if they have none yet. A zone they chose is never overwritten. - The picker is searchable and shows each zone's current UTC offset, so you can type a city or an offset such as `+02`. - Pick **UTC** to read times the way events record them. Times shown in UTC are labelled as such; times in your own zone are not. Event and issue times are always stored in UTC. The server's own log lines are in UTC too, unless `RUSTRAK_LOG_TIMEZONE` names another zone for them. ## Theme **Display** offers **Light**, **Dark** or **System**, which follows your operating system. ## Over the API `PATCH /auth/me` sets the language and time zone. It takes your dashboard session, not an API token: ```bash curl -X PATCH -b cookies.txt -H "Content-Type: application/json" \ -d '{"language": "zh", "timezone": "Asia/Shanghai"}' \ "$RUSTRAK_URL/auth/me" ``` | Body | Effect | |---|---| | `{"language": "zh"}` | Sets the language, leaves the time zone alone | | `{"language": null}` | Clears it: back to following the browser | | `{}` | Changes nothing | Any well-formed language tag and any real time zone name is accepted. A language the dashboard has no translation for falls back like the browser's would. ## Add a language A new language is one file of translated phrases, with no server change. See [Contributing](https://rustrak.dev/reference/contributing#adding-a-language). --- # Database Backends Rustrak supports two database backends compiled as separate Docker images. Both images include the dashboard; they differ only in where data is stored. ## Comparison | | SQLite | PostgreSQL | |---|--------|------------| | **Image tag** | `latest`, `vX.Y.Z` | `postgres`, `vX.Y.Z-postgres` | | **Setup** | None: data stored in a Docker volume | External PostgreSQL service required | | **Best for** | Personal use, low traffic, quick setup | Production, teams, high volume | | **Concurrency** | Single writer | Fully concurrent | | **Backup** | `sqlite3 .backup`, or the whole `/data` volume | `pg_dump` | --- ## SQLite (default) The `latest` image uses SQLite. No external service needed. ### Docker run ```bash docker run -d \ --name rustrak \ -p 8080:8080 \ -v rustrak_data:/data \ -e SESSION_SECRET_KEY=$(openssl rand -hex 32) \ -e CREATE_SUPERUSER=admin@example.com:changeme123 \ rustrak/rustrak-server:latest ``` > `DATABASE_URL` defaults to `sqlite:///data/rustrak.db`. Mount a volume at `/data` to persist data. ### Docker Compose ```yaml services: server: image: rustrak/rustrak-server:latest ports: - "8080:8080" volumes: - rustrak_data:/data environment: # DATABASE_URL defaults to sqlite:///data/rustrak.db, no need to set it - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:?generate one with openssl rand -hex 32} - CREATE_SUPERUSER=${CREATE_SUPERUSER} restart: unless-stopped volumes: rustrak_data: ``` ### DATABASE_URL format ```bash # Named Docker volume (recommended) DATABASE_URL=sqlite:///data/rustrak.db # Absolute path on host (bind mount) DATABASE_URL=sqlite:///var/lib/rustrak/rustrak.db ``` > Always use a Docker volume or bind mount. If you use a path inside the container without a mount, data will be lost when the container is removed. ### Backup ```bash # Use SQLite's online backup API (safe while server is running) docker run --rm \ -v rustrak_data:/data \ -v $(pwd)/backups:/backups \ alpine sh -c 'apk add -q sqlite && sqlite3 /data/rustrak.db ".backup /backups/rustrak-$(date +%Y%m%d).db"' ``` > Use `sqlite3 .backup` rather than `cp`. A plain file copy while the server is > running may capture an inconsistent state if a write is in progress. ### Durability Rustrak opens SQLite in WAL mode with `synchronous=NORMAL`, the pairing SQLite recommends for this journal mode. Commits are atomic and survive a crash of the Rustrak process itself. What they do not survive is an operating-system crash or a power loss: the last few seconds of committed events can be rolled back, because the write-ahead log is not flushed to disk on every commit. That trade is deliberate. It removes one disk flush per commit from the digest path, and for a stream of error events losing the newest few is a far smaller cost than making every write wait on the disk. If your deployment cannot accept it, use PostgreSQL. ### Reclaiming disk space Deleting data, by [retention or a cleanup](https://rustrak.dev/usage/storage), frees pages inside the database file. Rustrak creates SQLite databases with `auto_vacuum = INCREMENTAL`, and after each cleanup it hands the freed pages back to the filesystem a few megabytes at a time, so the file shrinks. A database created by an earlier version has SQLite's default, `auto_vacuum = NONE`. Its freed pages are reused for new data, so with retention on the file stops growing, but it never shrinks. Rustrak says so in its log at startup, and **Settings → Storage** shows how much space is held and a **Compact database** button that converts it once. This only concerns databases from earlier versions. A database created by the current version never shows the warning or the button, and one compacted once stops showing them for good. Compacting runs `VACUUM`, which rewrites only the data still in the file: - It needs free disk about **twice the data** (one copy in SQLite's temporary file, one in the write-ahead log). The button refuses when the disk next to the database is short of that. - It takes a few seconds per GB of data, and **no writes land while it runs**. Error events are kept on disk and processed afterwards; logs, traces and sessions that arrive meanwhile are lost. Run it at a quiet time. To do it by hand instead, stop Rustrak and run: ```bash sqlite3 /path/to/rustrak.db "PRAGMA auto_vacuum = INCREMENTAL; VACUUM;" ``` If the disk is short of room, make the copy with SQLite's own backup, which also takes what is still in the write-ahead log (copying `rustrak.db` alone can miss it after a crash), compact the copy there and move it back in place of the original and its `-wal` and `-shm` files: ```bash sqlite3 /path/to/rustrak.db ".backup /mnt/roomy/rustrak.db" sqlite3 /mnt/roomy/rustrak.db "PRAGMA auto_vacuum = INCREMENTAL; VACUUM;" ``` Check the result with `sqlite3 /path/to/rustrak.db "PRAGMA auto_vacuum;"`, which answers `2`. ### Limitations SQLite has one writer at a time. It suits up to about 1,000 events an hour reliably; above that, use PostgreSQL. Whichever backend you choose, run one Rustrak process per database. --- ## PostgreSQL Use the `:postgres` image tag when you need PostgreSQL. ### Docker run ```bash docker run -d \ --name rustrak \ -p 8080:8080 \ -v rustrak_data:/data \ -e DATABASE_URL=postgres://user:password@db-host:5432/rustrak \ -e SESSION_SECRET_KEY=$(openssl rand -hex 32) \ -e CREATE_SUPERUSER=admin@example.com:changeme123 \ rustrak/rustrak-server:postgres ``` ### Docker Compose ```yaml services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: rustrak POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?generate one with openssl rand -hex 16} POSTGRES_DB: rustrak volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U rustrak -d rustrak"] interval: 5s timeout: 5s retries: 5 restart: unless-stopped server: image: rustrak/rustrak-server:postgres ports: - "8080:8080" environment: - DATABASE_URL=postgres://rustrak:${POSTGRES_PASSWORD}@postgres:5432/rustrak - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:?generate one with openssl rand -hex 32} - CREATE_SUPERUSER=${CREATE_SUPERUSER} volumes: # Uploaded source maps. The database lives in PostgreSQL. - rustrak_data:/data depends_on: postgres: condition: service_healthy restart: unless-stopped volumes: postgres_data: rustrak_data: ``` The `/data` volume holds uploaded source maps, which stay on disk rather than in the database. ### DATABASE_URL format ```bash # Basic DATABASE_URL=postgres://username:password@host:5432/database # With SSL (recommended for production) DATABASE_URL=postgres://username:password@host:5432/database?sslmode=require ``` ### Connection pool tuning ```bash DATABASE_MAX_CONNECTIONS=10 # Default DATABASE_MIN_CONNECTIONS=1 # Default # High traffic DATABASE_MAX_CONNECTIONS=25 DATABASE_MIN_CONNECTIONS=5 ``` ### Backup ```bash # Dump pg_dump -h localhost -U rustrak rustrak > backup_$(date +%Y%m%d).sql # Restore psql -h localhost -U rustrak rustrak < backup.sql ``` --- ## Image tags reference | Tag | Backend | Arch | |-----|---------|------| | `latest` | SQLite | amd64, arm64 | | `vX.Y.Z` | SQLite | amd64, arm64 | | `postgres` | PostgreSQL | amd64, arm64 | | `vX.Y.Z-postgres` | PostgreSQL | amd64, arm64 | | `next` | SQLite, latest pre-release | amd64, arm64 | | `next-postgres` | PostgreSQL, latest pre-release | amd64, arm64 | --- ## Migrations Migrations run automatically on startup. Both backends have their own migration set: ``` apps/server/migrations/ ├── postgres/ # PostgreSQL migrations └── sqlite/ # SQLite migrations ``` If you see a migration error, ensure your `DATABASE_URL` scheme (`sqlite://` or `postgres://`) matches the image tag you are using. --- # Environment Variables All configuration options for the Rustrak server. None is needed to start it except `SESSION_SECRET_KEY` in production: the defaults run SQLite, serve the dashboard and listen on `8080`. ## Server | Variable | Default | Description | |----------|---------|-------------| | `HOST` | `0.0.0.0` | TCP bind address | | `PORT` | `8080` | Server port, for the API and the dashboard | | `RUST_LOG` | `info` | Log level (trace, debug, info, warn, error) | | `RUSTRAK_LOG_TIMEZONE` | `UTC` | IANA timezone name for log timestamp display (e.g. `Asia/Shanghai`) | | `PUBLIC_URL` | *(none)* | Public-facing URL used in DSN strings and alert links | ### HOST The network interface the server listens on. The default (`0.0.0.0`) means "all interfaces" and is correct for Docker, so you don't need to set it. Only change this if you want to restrict access to a specific interface: ```bash HOST=127.0.0.1 # loopback only, e.g. for local dev behind a local proxy ``` > **Docker note:** If you set `HOST=127.0.0.1` inside a container, the port mapping won't work and the server will be unreachable. ### PUBLIC_URL The address your SDKs and your team reach the server on. Rustrak writes it into every DSN and, unless `DASHBOARD_URL` says otherwise, into the issue links in alert notifications. Must include the scheme (`http://` or `https://`). Trailing slashes are stripped automatically. ```bash # Production (behind reverse proxy) PUBLIC_URL=https://rustrak.example.com # Self-hosted with custom port PUBLIC_URL=http://192.168.1.10:9090 ``` **Why this matters:** `HOST=0.0.0.0` is the TCP *bind* address. It means "listen on all interfaces" and is not a routable address. Without `PUBLIC_URL`, the DSN shown in the dashboard contains `http://0.0.0.0:8080/...`, which your SDK cannot reach. Set it even locally, e.g. `PUBLIC_URL=http://localhost:8080`. ### RUSTRAK_LOG_TIMEZONE Converts the timestamps in the server's process logs (stdout: HTTP requests, background workers, event digestion, everything under `RUST_LOG`) to a specific IANA timezone for easier correlation with logs from other systems. ```bash RUSTRAK_LOG_TIMEZONE=Asia/Shanghai ``` Use an IANA zone name (e.g. `Asia/Shanghai`, `America/New_York`, `Europe/Madrid`), not a fixed UTC offset, so daylight saving time is handled correctly. If unset, or set to a name that isn't a recognized IANA zone, logs fall back to UTC and the server still starts normally (a warning is logged when the value is invalid). **Scope:** this only affects how log lines are displayed. Event and issue timestamps are always stored and returned as UTC. ## Dashboard The dashboard is on by default, served from `/` on the same port and origin as the API. Nothing here needs setting for that. | Variable | Default | Description | |----------|---------|-------------| | `RUSTRAK_DASHBOARD` | `on` | Whether to serve the dashboard at all. `off` keeps the server API-only | | `DASHBOARD_URL` | `PUBLIC_URL` | Base URL for the issue links in notifications | | `RUSTRAK_DASHBOARD_DIR` | `./static` | Where the compiled dashboard lives, relative to the working directory or absolute | ### RUSTRAK_DASHBOARD `off` keeps the dashboard unmounted even though the image carries a build. Use it for a server that should answer the API only, or when the dashboard runs from [another host](https://rustrak.dev/configuration/production#dashboard-on-its-own-host). ```bash RUSTRAK_DASHBOARD=off ``` `on`, `off`, `true`, `false`, `1` and `0` are accepted. Anything else refuses to start, deliberately: a typo that silently left the dashboard on would expose a UI you meant to keep off the box. ### DASHBOARD_URL The issue links in alert notifications follow `PUBLIC_URL`, because the server serves the dashboard itself. Set `DASHBOARD_URL` only when people open the dashboard on a different host than `PUBLIC_URL`: a separate ingestion hostname, or a `rustrak-ui` container. With neither set, the links use the server's bind address. ### RUSTRAK_DASHBOARD_DIR The image already puts the dashboard at `/app/static`, which is what the default resolves to, so you do not normally set this. The dashboard is mounted when the directory holds an `index.html` and skipped when it does not. ```bash # Serve a dashboard you built and mounted yourself RUSTRAK_DASHBOARD_DIR=/srv/rustrak-dashboard ``` `RUSTRAK_API_URL` is not a server variable. It belongs to the optional `rustrak/rustrak-ui` image, for a dashboard [on its own host](https://rustrak.dev/configuration/production#dashboard-on-its-own-host), and names the server that image proxies the API to. ### The update check The dashboard compares the running server's version against the release feed published on GitHub Pages, and shows a notice when a newer one exists. The request is made by each reader's browser. Nothing about the instance is sent: it is a plain `GET` for a public JSON file, and a blocked or unreachable feed means no notice appears. ## Security | Variable | Default | Description | |----------|---------|-------------| | `SSL_PROXY` | `false` | Set to `true` when behind HTTPS proxy (nginx, Cloudflare) | | `SESSION_SECRET_KEY` | (random) | 64-character key for session encryption | ### SSL_PROXY Set to `true` when running behind a reverse proxy that terminates SSL (nginx, Cloudflare, etc.). This enables secure cookies. ```bash SSL_PROXY=true ``` **When `SSL_PROXY=true`:** - Cookies are sent only over HTTPS (`Secure` flag enabled) - `SESSION_SECRET_KEY` becomes **required** ### SESSION_SECRET_KEY Generate with: ```bash openssl rand -hex 32 ``` - **Development**: Optional (a random key is used, sessions don't persist across restarts) - **Production with `SSL_PROXY=true`**: Required The key must be at least 64 characters, which is what `openssl rand -hex 32` produces: the cookie key is a 256-bit signing key followed by a 256-bit encryption key. Anything shorter is refused at startup with the length it received, so `openssl rand -base64 32` (44 characters) will not do. Changing the key invalidates every existing session. ## OpenID Connect SSO Optional sign-in through Pocket ID, Authentik, Keycloak, Google or any other OpenID Connect provider. SSO is off unless `OIDC_ISSUER_URL` is set. See [Single Sign-On](https://rustrak.dev/configuration/sso) for how it works, a step-by-step setup and troubleshooting. | Variable | Default | Description | |----------|---------|-------------| | `OIDC_ISSUER_URL` | *(disabled)* | Provider issuer URL used for discovery | | `OIDC_CLIENT_ID` | *(required)* | Client ID issued to Rustrak | | `OIDC_CLIENT_SECRET` | *(required)* | Client secret issued to Rustrak | | `OIDC_REDIRECT_URL` | *(required)* | Exact callback URL, ending in `/auth/sso/callback` | | `OIDC_PROVIDER_NAME` | `SSO` | Label on the login button | | `OIDC_SCOPES` | `openid email profile` | Space-separated scopes | | `OIDC_AUTO_PROVISION` | `false` | Create an account for someone whose email has none | | `OIDC_LINK_EXISTING_ACCOUNTS` | `false` | Link to an existing account without asking for its password | | `OIDC_REQUIRE_EMAIL_VERIFIED` | `true` | Refuse identities whose email the provider has not verified | | `OIDC_ALLOWED_DOMAINS` | *(all)* | Comma-separated email domains allowed to use SSO | | `OIDC_GROUPS_CLAIM` | `groups` | Claim that carries the identity's groups | | `OIDC_ADMIN_GROUPS` | *(none)* | Comma-separated groups that make someone an owner (`admin` in the API) | | `OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Refuse password sign-in, leaving SSO as the only way in | ## Database | Variable | Default | Description | |----------|---------|-------------| | `DATABASE_URL` | `sqlite:///data/rustrak.db` | Database connection string. Required only for PostgreSQL | | `DATABASE_MAX_CONNECTIONS` | `10` | Maximum pool connections | | `DATABASE_MIN_CONNECTIONS` | `1` | Minimum pool connections | | `DATABASE_ACQUIRE_TIMEOUT_SECS` | `5` | How long a request waits for a free connection before failing | | `DATABASE_IDLE_TIMEOUT_SECS` | `600` | How long an unused connection stays open | | `DATABASE_MAX_LIFETIME_SECS` | `1800` | How long a connection lives before it is replaced | ### DATABASE_URL The backend is chosen by the image: `latest` is SQLite, `postgres` is PostgreSQL. See [Database backends](https://rustrak.dev/configuration/database). **SQLite** (optional, the default above is right for Docker): ```bash # Absolute path DATABASE_URL=sqlite:///var/lib/rustrak/rustrak.db # In-memory (for testing only, data lost on restart) DATABASE_URL=sqlite::memory: ``` **PostgreSQL** (required with the `postgres` image): ```bash # Format postgres://username:password@host:port/database # Examples DATABASE_URL=postgres://rustrak:rustrak@postgres:5432/rustrak # Docker DATABASE_URL=postgres://user:pass@host:5432/rustrak?sslmode=require # With SSL ``` ## Rate Limiting | Variable | Default | Description | |----------|---------|-------------| | `MAX_EVENTS_PER_MINUTE` | `60000` | Max events/minute (global) | | `MAX_EVENTS_PER_HOUR` | `1000000` | Max events/hour (global) | | `MAX_EVENTS_PER_PROJECT_PER_MINUTE` | `20000` | Max events/minute per project | | `MAX_EVENTS_PER_PROJECT_PER_HOUR` | `300000` | Max events/hour per project | The defaults are a brake for a runaway loop that would fill the disk, not a cap on ordinary spikes. Windows are fixed minutes and hours, and the limit is exact: once a window is full, ingest answers `429` with `Retry-After` and `X-Sentry-Rate-Limits`, and the SDK waits before sending again. An event that was already accepted when its window filled is dropped rather than stored, and counted in the project's `rate_limited_event_count`. ## Storage | Variable | Default | Description | |----------|---------|-------------| | `INGEST_DIR` | `/tmp/rustrak/ingest` | Temporary event storage | | `SOURCEMAP_STORAGE_PATH` | `/data/sourcemaps` | Directory for source map chunk storage | | `SOURCEMAP_CACHE_MB` | `64` | Memory kept for parsed source maps across events | | `MAX_CHUNK_SIZE_BYTES` | `10485760` (10 MiB) | Largest single chunk a source map or debug file upload may send | | `SESSION_FLUSH_INTERVAL_SECS` | `30` | How often release health counts are written to the database | | `SESSION_CARDINALITY_CAP` | `10000` | Release and environment pairs tracked per project; more are counted together as `` | ### SOURCEMAP_STORAGE_PATH Where Rustrak stores source map chunks uploaded by Sentry SDKs. The default is inside the `/data` volume, so with `-v rustrak_data:/data` they persist with no setting. Chunks are stored in a content-addressable layout: a SHA-1 hash like `abc123...` is written to `{SOURCEMAP_STORAGE_PATH}/ab/c123....map`. ```bash SOURCEMAP_STORAGE_PATH=/var/lib/rustrak/sourcemaps ``` ### SOURCEMAP_CACHE_MB Once a source map has been read and parsed for one event, Rustrak keeps the parsed form in memory so the next event that hits the same file skips both steps. This budget caps that memory, measured as the size of the source map files it holds, and the least recently used map is dropped first. A map larger than the whole budget is never kept. ```bash SOURCEMAP_CACHE_MB=64 ``` The cache only saves the file read and the parse. Which file a `debug_id` points to is looked up in the database on every event, so a deleted or re-uploaded source map takes effect immediately. Size it to hold the maps your errors actually hit: a handful of large Next.js server bundles is usually enough, and the default covers a 15 MB map four times over. ## Email Alerts (SMTP) Global SMTP settings for email notifications and team invitations. Alert channels can override these. | Variable | Default | Description | |----------|---------|-------------| | `SMTP_HOST` | - | SMTP server hostname | | `SMTP_PORT` | `587` | SMTP port (587 for STARTTLS, 465 for SSL) | | `SMTP_TLS` | by port | `starttls`, `tls` (SSL) or `none`. Unset: `tls` on port 465, `starttls` elsewhere. `none` is for a relay on a trusted network or a local test server | | `SMTP_USERNAME` | - | SMTP username | | `SMTP_PASSWORD` | - | SMTP password | | `SMTP_FROM` | `alerts@rustrak.local` | Default sender address | ## Bootstrap | Variable | Description | |----------|-------------| | `CREATE_SUPERUSER` | Create admin user on startup (`email:password`) | | `RUSTRAK_BOOTSTRAP_TOKEN` | Set to anything to create an API token on startup | ```bash CREATE_SUPERUSER="admin@example.com:secure-password" ``` Only creates the user if the database has no users yet. After that it is ignored. `RUSTRAK_BOOTSTRAP_TOKEN` creates one API token when the instance has none, for automation that needs the API before anyone signs in. The token is printed once to the server's standard error, not to the log, so copy it from the first start's output. ## Prometheus metrics | Variable | Default | Description | |----------|---------|-------------| | `RUSTRAK_METRICS` | `off` | `on` serves Prometheus metrics on `/metrics` | `RUSTRAK_METRICS=on` enables `GET /metrics` on the server's existing port. It is off by default (the route returns 404); only `on` and `off` are accepted. This is independent of anonymous telemetry and does not send data anywhere. The scrape reports ingest totals and latency, digest outcomes, 5xx responses by matched route, and failed alerts by provider. It also reports the pending spool (count, bytes and oldest-file age) and database size, read at most once every 15 seconds. Counters reset on restart, not when anonymous telemetry sends a report. Labels never contain project IDs, user details or event contents. `/metrics` has no application authentication. Keep it on a trusted network or restrict it at the reverse proxy. The standalone dashboard image does not forward it, so point Prometheus at the server. ```bash RUSTRAK_METRICS=on curl http://localhost:8080/metrics ``` ## Telemetry | Variable | Default | Description | |----------|---------|-------------| | `RUSTRAK_TELEMETRY` | `on` | Anonymous usage report every 6 hours. `off` sends nothing | | `DO_NOT_TRACK` | unset | `1` sends nothing either; the cross-tool convention | Either variable on its own is enough to turn it off. What the report contains, field by field, is on the [Telemetry](https://rustrak.dev/configuration/telemetry) page. ## Complete Example ### SQLite (image tag `latest`) ```bash SESSION_SECRET_KEY=generate-your-own-with-openssl-rand-hex-32 PUBLIC_URL=https://rustrak.example.com SSL_PROXY=true ``` That is the whole configuration. The database defaults to `/data/rustrak.db` and the dashboard is served from `/`. ### PostgreSQL (image tag `postgres`) ```bash DATABASE_URL=postgres://rustrak:password@postgres:5432/rustrak # Security (required behind an HTTPS proxy) SSL_PROXY=true SESSION_SECRET_KEY=generate-your-own-with-openssl-rand-hex-32 # Server: SDKs, the dashboard and alert links all use this address PUBLIC_URL=https://rustrak.example.com RUST_LOG=info # Database Pool DATABASE_MAX_CONNECTIONS=20 # Rate Limiting (only to lower the defaults on small hardware) MAX_EVENTS_PER_MINUTE=10000 MAX_EVENTS_PER_HOUR=200000 # Email Alerts (optional, can also be set per channel) SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USERNAME=alerts@example.com SMTP_PASSWORD=your-smtp-password SMTP_FROM=alerts@example.com ``` --- # GitHub Connect Rustrak to GitHub and the code behind every error is one click away. For each project you choose a repository, and from then on: - **Every stack frame of your app links to its line on GitHub.** - **Issues link to GitHub issues.** Open one from Rustrak, or link one that already exists. Resolving, reopening, commenting and assigning stay in sync both ways. - **Commits resolve issues.** A commit on the default branch whose message says `Fixes WEB-12` resolves Rustrak issue `WEB-12`. - **Suspect commits.** An issue lists the recent commits that touched the file it broke in, shortly before it first appeared. - **Code owners.** The repository's `CODEOWNERS` file names who owns each issue. - **Automatic issues.** New errors can open a GitHub issue, filtered by level, environment and number of events. ## How it connects Rustrak creates its own **private GitHub App** for your server. No shared app and no token sit between your Rustrak and GitHub: the app belongs to you, and it only sees the repositories you pick. 1. In Rustrak, open **Settings → Integrations → GitHub** and press **Connect GitHub**. Type an organization name first to create the app there instead of under your own account. 2. GitHub shows its **Create GitHub App** page, already filled in. Confirm it. You can rename the app there if the name is taken. 3. Back in Rustrak, press **Choose repositories** and install the app on the account or organization whose repositories you want. 4. In each project, open **Settings → Repository** and pick its repository. Connecting GitHub takes `github-app:update`, which owners always hold. Choosing a project's repository takes `project-github:update` on that project. See [Roles & Permissions](https://rustrak.dev/usage/roles). ## What GitHub must be able to reach Rustrak calls GitHub to open issues, comment and read `CODEOWNERS`, so those work wherever Rustrak runs. The other direction, GitHub telling Rustrak that an issue was closed or that commits were pushed, uses **webhooks**, and GitHub has to be able to reach your server for them. Set `PUBLIC_URL` to the address people and GitHub use before connecting: ```bash PUBLIC_URL=https://rustrak.example.com ``` The app is created with its webhook pointing at `$PUBLIC_URL/api/github/webhook`. **Settings → Integrations** shows when the last webhook arrived, so you can tell at a glance whether GitHub reaches you. Behind a firewall, everything but the GitHub-to-Rustrak sync keeps working. ## Stack traces and path mapping The path in a stack frame is rarely the path in the repository: an app built into a container reports `/app/lib/db.ts` for the file the repository keeps at `src/lib/db.ts`. A **path mapping** in the project's **Settings → Repository** says how one becomes the other: | Path in the stack trace | Path in the repository | |---|---| | `/app/` | `src/` | The first mapping whose prefix matches a frame wins. Without any, Rustrak drops bundler prefixes such as `webpack:///` or `app:///` and a leading `/`, which is right for many projects. Links point at the default branch. ## Resolving from commits Any of these words followed by an issue's short ID resolves it when the commit reaches the default branch: `fix`, `fixes`, `fixed`, `close`, `closes`, `closed`, `resolve`, `resolves`, `resolved`. ```text Guard the session lookup Fixes WEB-12, closes WEB-14 ``` The issue's activity names the commit that resolved it. A commit only resolves issues of projects connected to the repository it was pushed to. ## Assignees Each person sets their GitHub username under **Settings → Integrations → GitHub**. Assign them an issue in Rustrak and its linked GitHub issues are assigned to them; assign them on GitHub and the Rustrak issue follows. ## Automatic issues In the project's **Settings → Repository**, **Open GitHub issues automatically** opens one GitHub issue per new Rustrak issue. Without filters that is every new error, so narrow it: | Filter | Opens the issue when | |--------|----------------------| | **Level** | The event is at least this level. An event with no level counts as `error`. | | **Environments** | The event's environment is one of these. Empty means every environment. | | **Minimum events** | The issue reaches this many events. It opens once, on the event that reaches the count. | `Minimum events: 10` with `Environments: production` keeps a one-off error on a laptop out of your repository. An issue that already has a GitHub issue linked never gets a second one. --- # GitLab GitLab's **Error Tracking** panel can show Rustrak's issues inside GitLab. GitLab does not receive your errors: your apps keep sending them to Rustrak with any Sentry SDK, and GitLab reads them from Rustrak's API. From a GitLab project you can then: - **List the project's errors** under **Monitor → Error tracking**, filter them by status and search them. - **Open an error** with its stack trace, from its latest event. - **Resolve, ignore or reopen it.** The change is made in Rustrak. - **Jump to Rustrak** with **View in Sentry**. GitLab talks to Rustrak as it would to a self-hosted Sentry: Rustrak answers the part of Sentry's API that GitLab reads. ## Connect a GitLab project 1. In Rustrak, create an API token under **Settings → API Tokens**. GitLab acts with that token, so it sees the projects its owner sees. Reading issues needs `issues:read` on the project and resolving them `issues:update`, on both the token and its owner. 2. In GitLab, open the project's **Settings → Monitor → Error Tracking**, enable it and choose **Sentry** as the backend. 3. Set **Sentry API URL** to your Rustrak address, for example `https://rustrak.example.com`, and paste the token as the **Auth Token**. 4. Press **Connect**, pick the Rustrak project from the list and save. The project list shows each project under an organization called **Rustrak**: Rustrak has no organizations, so one stands in for the whole server. ## What GitLab must be able to reach GitLab calls Rustrak, never the other way round, so only GitLab needs a route to Rustrak. - GitLab refuses an API URL on `localhost` or on a private network address. Give it an address it accepts as public. - Rustrak must answer at the root of that address: GitLab rejects a URL with a path such as `https://example.com/rustrak`. - **View in Sentry** opens `///issues/` on the same address, which Rustrak redirects to the issue in its dashboard. Set `PUBLIC_URL` (or `DASHBOARD_URL`) to the address people use, so the redirect lands there. ## Differences from Sentry - **Search** understands `is:unresolved`, `is:resolved`, `is:ignored` and `environment:` (or `!environment:` to leave one out); everything else in the search box matches the issue's title, culprit and location as free text. - **Sorting** by last seen, first seen and number of events works. Sorting by number of users does not. - **Users** counts the distinct users of the last 24 hours, as Rustrak's own issue list does. - **Creating a GitLab issue from an error** works, but GitLab cannot link the two back, because Rustrak connects no repositories through Sentry's integrations. --- # Production Checklist Essential steps before going live. ## Security ### 1. Generate secure secrets Session secret, required when using HTTPS: ```bash openssl rand -hex 32 ``` Database password (do not reuse this one as the session secret): ```bash openssl rand -base64 24 ``` ### 2. Use HTTPS Put Rustrak behind a reverse proxy that terminates SSL. See [Reverse proxy](#reverse-proxy) below for the routing. ### 3. Enable SSL_PROXY When running behind HTTPS, enable secure cookies: ```bash # In your .env or environment SSL_PROXY=true SESSION_SECRET_KEY=$(openssl rand -hex 32) ``` This ensures: - Cookies are only sent over HTTPS - Sessions persist across server restarts ### 4. Database security With PostgreSQL: ```bash # Enable SSL DATABASE_URL=postgres://user:pass@host:5432/rustrak?sslmode=require ``` - Use strong passwords - Restrict network access - Enable automated backups ## Reverse proxy The container needs no published host port. Put it on the proxy's network, delete the `ports:` block from `docker-compose.yml`, and let the proxy reach it on `8080`. **One container answers both the dashboard and the API**, so there is only one upstream and only one address to get right: - **`PUBLIC_URL` is public.** It is the host Rustrak writes into every DSN, so it has to be an address your SDKs can reach from outside. There is no internal dashboard-to-server address to configure. The dashboard is static files served from `/`, and the browser calls the API on the origin it was loaded from: the public host, through the proxy, like every other request. ### One hostname The usual case. Everything lives behind one name: ```yaml # docker-compose.yml services: server: image: rustrak/rustrak-server:latest environment: - SESSION_SECRET_KEY=${SESSION_SECRET_KEY:?generate one with openssl rand -hex 32} - SSL_PROXY=true - PUBLIC_URL=https://rustrak.example.com volumes: - rustrak_data:/data networks: [proxy] restart: unless-stopped volumes: rustrak_data: networks: proxy: external: true ``` With PostgreSQL it is the same service on the `postgres` image, plus a `DATABASE_URL`; see [Installation](https://rustrak.dev/getting-started/installation#postgresql). ```nginx server { listen 443 ssl http2; server_name rustrak.example.com; location / { proxy_pass http://server:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Caddy says the same thing in four lines, and Traefik in two labels (`traefik.http.routers..rule=Host(...)` plus the service port). No path rules, because there is nothing to split: the server decides for itself which paths are the API's and which are the dashboard's, and the two lists cannot disagree because there is only one of them. ### Two hostnames Worth it if you want ingestion on a name of its own: an SDK's `POST /api//envelope/` is unauthenticated traffic from the open internet, and separating it lets the proxy rate-limit or firewall that name without touching the one your team signs in on. Point both names at the same container. Set `PUBLIC_URL` to the ingestion one, since that is the address the DSN has to carry, and `DASHBOARD_URL` to the one your team opens, so alert links land on the dashboard: ```bash PUBLIC_URL=https://ingest.example.com DASHBOARD_URL=https://rustrak.example.com ``` ```nginx server { listen 443 ssl http2; server_name rustrak.example.com; # the team location / { proxy_pass http://server:8080; } } server { listen 443 ssl http2; server_name ingest.example.com; # SDKs and the DSN location /api/ { proxy_pass http://server:8080; } } ``` ### Dashboard on its own host Most installations never need this. It is for keeping the machine that holds your data from serving any frontend, with the dashboard on another host. `rustrak/rustrak-ui` is the same dashboard bundle behind nginx, which proxies `/api`, `/auth`, `/health`, `/docs` and `/api-docs` to the server named by `RUSTRAK_API_URL`. On the server, turn its own copy of the dashboard off and point alert links at the dashboard host: ```bash RUSTRAK_DASHBOARD=off PUBLIC_URL=https://ingest.example.com DASHBOARD_URL=https://rustrak.example.com ``` On the dashboard host: ```yaml services: ui: image: rustrak/rustrak-ui:latest ports: ["3000:3000"] environment: - RUSTRAK_API_URL=https://ingest.example.com ``` The browser still sees a single origin, the one the dashboard was loaded from, so the session cookie stays first-party and no CORS applies. `RUSTRAK_API_URL` is read when the container starts, not when the image is built, and its host is resolved per request, so the server can move without restarting the dashboard. The image is about 30MB and idles under 15MB of memory. `RUSTRAK_API_URL` is an origin with no path (`https://ingest.example.com`, not `https://ingest.example.com/`; a trailing slash is dropped, a path refuses to start). Across hosts make it `https://`: the session cookie travels on that hop, and nginx verifies the server's certificate against the system bundle. Plain `http://server:8080` works only when the two containers share a Docker network. ## Performance ### Resource allocation | Component | CPU | Memory | |-----------|-----|--------| | Server (API + dashboard) | 1 vCPU | 256MB | | Database | 1 vCPU | 512MB | The dashboard costs nothing at runtime: it is static files the server reads once at startup and hands out from memory. The whole process idles under 100MB of memory. ### Rate limiting The defaults stop a runaway loop without cutting off ordinary spikes, so most installs never touch them. Lower them when the hardware is small: ```bash # Constrained resources MAX_EVENTS_PER_MINUTE=1000 MAX_EVENTS_PER_HOUR=20000 ``` See [Rate Limiting](https://rustrak.dev/configuration/environment#rate-limiting) for how the windows work. ## Reliability ### Health checks ```bash GET /health # Liveness GET /health/ready # Readiness ``` ### Restart policy ```yaml # docker-compose.yml services: server: restart: unless-stopped ``` ### Backups **PostgreSQL:** ```bash # Daily backup pg_dump -h localhost -U rustrak rustrak > backup_$(date +%Y%m%d).sql # Restore psql -h localhost -U rustrak rustrak < backup.sql ``` **SQLite:** everything lives in the `/data` volume: the database at `/data/rustrak.db` and uploaded source maps under `/data/sourcemaps`. The simplest backup is the whole volume, with the server stopped: ```bash docker stop rustrak docker run --rm -v rustrak_data:/data -v "$PWD":/backup alpine \ tar czf /backup/rustrak-$(date +%Y%m%d).tgz -C /data . docker start rustrak ``` With Docker Compose, the service is `server` and Compose prefixes the volume with the project name (`docker volume ls` shows it, usually `_rustrak_data`): ```bash docker compose stop server docker run --rm -v _rustrak_data:/data -v "$PWD":/backup alpine \ tar czf /backup/rustrak-$(date +%Y%m%d).tgz -C /data . docker compose start server ``` To back up only the database while the server runs, use SQLite's online backup rather than `cp`, which can capture a write half done. It writes the copy to the current directory, off the volume, and does not include source maps: ```bash docker run --rm -v rustrak_data:/data -v "$PWD":/backup alpine sh -c \ 'apk add -q sqlite && sqlite3 /data/rustrak.db ".backup /backup/rustrak-$(date +%Y%m%d).db"' ``` **Source maps with PostgreSQL** live in the server's `/data` volume, not in the database. Back up that volume too. ## Pre-launch checklist - [ ] Secure secrets generated (`SESSION_SECRET_KEY`) - [ ] HTTPS configured (reverse proxy) - [ ] `SSL_PROXY=true` set - [ ] `PUBLIC_URL` set to your public domain (e.g. `https://rustrak.example.com`) - [ ] A named volume mounted at `/data` - [ ] Telemetry left on or turned off (`RUSTRAK_TELEMETRY=off`), deliberately - [ ] Dashboard served (the default) or `RUSTRAK_DASHBOARD=off` if this server should answer the API only - [ ] Database SSL enabled - [ ] Rate limits set - [ ] Health checks monitored - [ ] `RUSTRAK_METRICS` off, or `/metrics` reachable only from your network - [ ] Backup strategy in place - [ ] Restart policies configured - [ ] Admin user created - [ ] Test event sent and received - [ ] DSN shown in dashboard contains correct domain (not `0.0.0.0`) --- # Single Sign-On (OIDC) Rustrak can sign people in through an OpenID Connect provider such as [Pocket ID](https://pocket-id.org), Authentik, Keycloak or Google. Instead of keeping a separate password for Rustrak, people press **Continue with Pocket ID** on the login page, prove who they are to the provider, and come back signed in. Why run it this way: - **One login for everything you host.** The same account opens Rustrak, Grafana, Immich or anything else connected to the provider. - **One place to remove access.** Disable someone at the provider and they can no longer sign in to Rustrak, or to anything else. - **Stronger sign-in for free.** Passkeys, two-factor and password rules are the provider's job, and Rustrak inherits them. SSO is optional and sits beside password login: both keep working, and nothing changes until `OIDC_ISSUER_URL` is set. ## How a login works ```text Browser Rustrak Provider │ Continue with Pocket ID │ │ │ ─────────────────────────▶ │ state, nonce and PKCE │ │ │ kept in the session cookie │ │ ◀─ redirect to provider ── │ │ │ ───────────────────────────────────────────────────────▶ │ sign in, consent │ ◀──────────── redirect to /auth/sso/callback?code&state ─ │ │ ─────────────────────────▶ │ ── code + PKCE verifier ──▶ │ │ │ ◀──── signed ID token ───── │ │ │ checks signature, nonce, │ │ │ email; finds the account │ │ ◀── signed in, go to / ─── │ │ ``` Each piece guards against something specific: | Piece | What it prevents | |---|---| | `state` | A forged callback that would sign you in to someone else's session | | `nonce` | An ID token issued for a different login being replayed into this one | | PKCE | A stolen authorization code being exchanged by anyone but Rustrak | | Signature check | A token that did not come from your provider | All three one-time values live in Rustrak's encrypted session cookie and are erased as soon as the callback reads them, so a callback URL cannot be used twice. Rustrak remembers each person by the provider's **issuer and subject**, an identifier that never changes. If someone's email changes at the provider, their next login still reaches the same Rustrak account. ## Set up Pocket ID These steps assume Rustrak is served at `https://rustrak.example.com` and Pocket ID at `https://id.example.com`. Replace both with your own addresses. ### 1. Create the client in Pocket ID In Pocket ID go to **Settings → Administration → OIDC Clients → Add OIDC Client** and fill in: - **Name:** `Rustrak`. This is what people see on Pocket ID's consent screen. - **Callback URL:** `https://rustrak.example.com/auth/sso/callback`, exactly. It is the address Rustrak is reached at followed by `/auth/sso/callback`. - **PKCE:** on. Rustrak always sends it, so requiring it costs nothing. Save, then open the client's **Credentials** tab and add a client secret. Copy the **Client ID** and the **secret**. ### 2. Let people use the client Open the client's **Allowed User Groups** tab. A new client admits nobody: either choose the groups that may use Rustrak, or press **Unrestricted** to let every Pocket ID user through. Skipping this step shows *"You are not allowed to access this service"* on Pocket ID after signing in. ### 3. Check each user's email is verified Rustrak only trusts an email the provider marks as verified. In Pocket ID, open **Users**, edit each person and use the **Mark as verified** button next to the email. An unverified email is refused at login (see [Troubleshooting](#troubleshooting)). ### 4. Configure Rustrak ```bash OIDC_ISSUER_URL=https://id.example.com OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_REDIRECT_URL=https://rustrak.example.com/auth/sso/callback OIDC_PROVIDER_NAME=Pocket ID # Your Pocket ID decides who has an account, so let new people in. OIDC_AUTO_PROVISION=true ``` Restart Rustrak. The log confirms it found the provider: ```text INFO rustrak] OpenID Connect SSO enabled (Pocket ID) ``` and the login page shows **Continue with Pocket ID** above the password form. If the provider cannot be reached or the configuration is incomplete, Rustrak refuses to start and says why, rather than showing a button that cannot work. ## Who can sign in The first time someone signs in through SSO, one of three things happens. **They are already linked.** They go straight in. This is every login after the first. **An account already has their email.** This is the usual case: you set up SSO on an instance where people, the administrator at least, already sign in with a password. Rustrak shows a **Link your account** page and asks for that account's password once. Given correctly, the SSO identity is attached and from then on the provider alone is enough. Asking for the password proves the account belongs to the person signing in, so this is safe whatever the provider. **Nobody has their email.** With `OIDC_AUTO_PROVISION=true` an account is created for them. The first account on an empty installation becomes an owner; every later one is a member who sees nothing until someone gives them a [role](https://rustrak.dev/usage/roles). With provisioning off, which is the default, they are turned away and someone has to invite them first. With `OIDC_ADMIN_GROUPS` set the provider decides the role instead; see [Roles from provider groups](#roles-from-provider-groups). ### Choosing the switches for your provider | Provider | `OIDC_AUTO_PROVISION` | `OIDC_ALLOWED_DOMAINS` | `OIDC_LINK_EXISTING_ACCOUNTS` | |---|---|---|---| | Your own Pocket ID, Authentik or Keycloak | `true` | optional | optional | | Google Workspace or Microsoft Entra | `true` | **your company domain** | leave `false` | | Public Google accounts | leave `false` | leave empty | leave `false` | - **`OIDC_AUTO_PROVISION`** lets in anyone the provider vouches for. That is exactly what you want when you control who has an account at the provider, and exactly what you do not want with a provider anyone can sign up to. Rustrak logs a warning at startup when it is on without a domain allowlist. - **`OIDC_ALLOWED_DOMAINS`** limits SSO to emails at the listed domains, for example `example.com,subsidiary.example`. It applies to every login, including people already linked. - **`OIDC_LINK_EXISTING_ACCOUNTS`** skips the password question and attaches the identity straight to the matching account, provided the email is verified. Only turn it on with a provider whose users cannot pick an email they do not own. With a provider that lets people choose their address, it would let someone take over an existing account by claiming its email. ## Roles from provider groups By default SSO never rewrites whether someone is an owner: a new account is a member and an existing one stays what it was. To let the provider decide, name the groups that mean owner, which the configuration and the API call `admin`: ```bash OIDC_ADMIN_GROUPS=rustrak-admins # OIDC_GROUPS_CLAIM=groups ``` From then on **every SSO login refreshes that account's global role**: someone in one of the listed groups becomes an `admin`, everyone else becomes a `member`. Membership is re-read on each login, so adding someone to the group promotes them the next time they sign in and taking them out demotes them the same way. Accounts that sign in with a password are never touched. Groups only decide owner or member. Provider groups do not map to the [roles](https://rustrak.dev/usage/roles) a member holds: give those in the dashboard. Worth knowing: - The first account on the installation (the one `CREATE_SUPERUSER` created) keeps its `admin` role even when its groups say otherwise. Every other safeguard protects that account from demotion, and this must not be the way around it. - Leaving `OIDC_ADMIN_GROUPS` unset leaves every role exactly as it is. - The first account on an installation is created as an owner even when it is in no group, so a fresh deployment cannot end up with no owner. After that, a mapping nobody satisfies leaves only members: keep at least one person in an admin group. The claim may be a JSON array (`["rustrak-admins", "everyone"]`) or a comma-separated string, and group names are compared exactly, including case. A provider that reports groups in `roles`, `memberOf` or anything else needs `OIDC_GROUPS_CLAIM` set to that name. Groups are read from the ID token only, so a provider that puts them in the UserInfo response instead will need to move them (or use a client scope that copies them into the token). Rustrak asks for `openid email profile` by default. If your provider only releases groups behind a requestable scope, add it to `OIDC_SCOPES`, for example `OIDC_SCOPES="openid email profile groups"`; others need a provider-specific client scope or claim mapping instead. A claim the provider does not send is an empty group list. With `OIDC_ADMIN_GROUPS` set, that demotes the account to `member` on its next SSO login. Check the token's groups before turning the mapping on. `OIDC_ADMIN_GROUPS` is itself comma-separated, so an admin group whose name contains a comma cannot be named here (the same is true for a single string claim). That is why only a string claim is split, and array entries never are: an array entry is one group, whatever it contains. ## SSO-only login `OIDC_DISABLE_PASSWORD_LOGIN=true` hides the password form and makes `POST /auth/login` answer `403`, leaving the provider as the only way in. The first account keeps working with its password as a documented break-glass. It is the account `CREATE_SUPERUSER` created, and blocking it too would leave a provider outage with no way back into the instance. Because the login page must not advertise which account that is, it offers one generic **Sign in with a password** link that reveals the form; every other account gets `403` from it. Invitations keep working, because without `OIDC_AUTO_PROVISION` they are the only way to create an account. Accepting one creates the account with the invitation's role but does not sign the browser in: the invitee then signs in through the provider. The password they chose is what confirms the link between their SSO identity and that account, unless `OIDC_LINK_EXISTING_ACCOUNTS` links it on the provider's word. Rustrak refuses to start when the switch is on without `OIDC_ISSUER_URL`, because that combination could not sign anyone in at all. ## Other providers Any provider that supports OpenID Connect discovery works. Register `https:///auth/sso/callback` as the redirect URI and point `OIDC_ISSUER_URL` at the provider's issuer: | Provider | `OIDC_ISSUER_URL` | |---|---| | Pocket ID | `https://id.example.com` | | Authentik | `https://auth.example.com/application/o//` | | Keycloak | `https://keycloak.example.com/realms/` | | Google | `https://accounts.google.com` | The issuer is the address that serves `/.well-known/openid-configuration`. Opening that URL in a browser is a quick way to check you have the right one: its `issuer` field must match `OIDC_ISSUER_URL` exactly. Rustrak asks for the `openid email profile` scopes. The provider has to return an `email` claim, and an `email_verified` claim set to true unless you set `OIDC_REQUIRE_EMAIL_VERIFIED=false`. ## Configuration reference | Variable | Default | Description | |----------|---------|-------------| | `OIDC_ISSUER_URL` | *(disabled)* | Provider issuer URL used for discovery. Setting it turns SSO on | | `OIDC_CLIENT_ID` | *(required)* | Client ID issued to Rustrak | | `OIDC_CLIENT_SECRET` | *(required)* | Client secret issued to Rustrak | | `OIDC_REDIRECT_URL` | *(required)* | Exact callback URL, ending in `/auth/sso/callback` | | `OIDC_PROVIDER_NAME` | `SSO` | Label on the login button | | `OIDC_SCOPES` | `openid email profile` | Space-separated scopes | | `OIDC_AUTO_PROVISION` | `false` | Create an account for someone whose email has none | | `OIDC_LINK_EXISTING_ACCOUNTS` | `false` | Link to an existing account without asking for its password | | `OIDC_REQUIRE_EMAIL_VERIFIED` | `true` | Refuse identities whose email the provider has not verified | | `OIDC_ALLOWED_DOMAINS` | *(all)* | Comma-separated email domains allowed to use SSO | | `OIDC_GROUPS_CLAIM` | `groups` | Claim carrying the identity's groups | | `OIDC_ADMIN_GROUPS` | *(none)* | Comma-separated groups that grant the global `admin` role | | `OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Refuse password sign-in; the first account stays as break-glass | The booleans accept `true/false`, `1/0`, `yes/no` and `on/off`. An empty value means unset. Anything else stops Rustrak at startup instead of silently falling back to the default. The issuer and every endpoint it advertises must use HTTPS. The one exception is a provider on the same machine (`localhost`, `127.0.0.1` or `::1`), which may use plain HTTP. ## Troubleshooting When an SSO login fails, the login page only says it failed, so it tells a stranger nothing about your accounts. The reason is in Rustrak's log, on a line that starts with `SSO browser callback failed:`. | Log message | Cause | Fix | |---|---|---| | `SSO provider did not verify the email address` | The provider sent `email_verified: false` | Verify the email at the provider. In Pocket ID: **Users → edit → Mark as verified** | | `No Rustrak account is linked to this SSO identity` | Nobody has this email and provisioning is off | Invite the person, or set `OIDC_AUTO_PROVISION=true` | | `Email domain is not allowed to use SSO` | The email is outside `OIDC_ALLOWED_DOMAINS` | Add the domain, or use an account at an allowed one | | `SSO provider did not supply an email address` | The provider returned no `email` claim | Make sure `OIDC_SCOPES` includes `email` and the provider releases it | | `SSO session expired; please try again` | The callback arrived without the values `/auth/sso/start` stored | See below | | `SSO callback state did not match` | The callback belongs to a different login attempt | Start again from the login page | | `SSO authorization was denied` | The provider returned an error, for example the person declined consent | Try again, and check the provider's own log | | `SSO authentication failed` | The provider refused the code exchange, usually a wrong client secret | Check `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET`. A line starting `OIDC code exchange failed` carries the provider's answer | | `SSO identity token is invalid` | The ID token failed its checks: wrong audience, issuer or signature | Check `OIDC_CLIENT_ID` and that `OIDC_ISSUER_URL` matches the `issuer` in the discovery document | | `Account is disabled` | The matching Rustrak account is deactivated | Reactivate it in Rustrak | | `Password login is disabled; sign in through SSO` | `OIDC_DISABLE_PASSWORD_LOGIN=true` and the account is not the first one | Sign in through the provider; the first account is the break-glass | | `OIDC_DISABLE_PASSWORD_LOGIN is on, but SSO is not configured` (at startup) | `OIDC_DISABLE_PASSWORD_LOGIN=true` without `OIDC_ISSUER_URL` | Configure SSO, or turn the switch off | **`SSO session expired`** almost always means the browser did not send Rustrak's session cookie back on the callback. Check that: - `OIDC_REDIRECT_URL` uses the **same host** you open Rustrak on. A dashboard opened at `http://127.0.0.1:8080` with a redirect to `http://localhost:8080/...` loses the cookie, because the browser treats them as different sites. - `SESSION_SECRET_KEY` is set. Without it Rustrak makes a new key at every start, and a restart between leaving for the provider and coming back invalidates the cookie. - The link was not opened twice. A callback URL works exactly once. **Errors shown by the provider, not by Rustrak:** - *"You are not allowed to access this service"* (Pocket ID): the client has no allowed user groups. See [step 2](#2-let-people-use-the-client). - *"Invalid redirect URI"* or similar: the callback URL registered at the provider differs from `OIDC_REDIRECT_URL`. They must match character for character, including `http` versus `https` and any trailing slash. **Rustrak will not start:** - `OIDC_CLIENT_ID is required when OIDC_ISSUER_URL is configured`: set all four of `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_REDIRECT_URL`, or none of them. - `OIDC discovery failed`: Rustrak could not read `/.well-known/openid-configuration`. Check the URL and that the Rustrak container can reach the provider over the network. - `must use HTTPS`: the issuer or one of its endpoints is plain HTTP on a host other than `localhost`. - `OIDC_DISABLE_PASSWORD_LOGIN is on, but SSO is not configured`: set `OIDC_ISSUER_URL` too, or turn the switch off. Password login would be the only way in and it is off. ## Try it locally You can run the whole flow on your machine with Pocket ID in Docker. Plain HTTP is accepted because both run on `localhost`. ```bash docker run -d --name pocket-id -p 1411:1411 \ -e APP_URL=http://localhost:1411 \ -e ENCRYPTION_KEY=$(openssl rand -hex 32) \ -v pocket-id-data:/app/data \ ghcr.io/pocket-id/pocket-id ``` 1. Open `http://localhost:1411/setup` and create the first Pocket ID user. You can skip adding a passkey: `docker exec pocket-id /app/pocket-id one-time-access-token ` prints a one-time sign-in link whenever you need one. 2. Follow [Set up Pocket ID](#set-up-pocket-id) with `http://localhost:8080/auth/sso/callback` as the callback URL. 3. Start Rustrak with `OIDC_ISSUER_URL=http://localhost:1411`, `OIDC_REDIRECT_URL=http://localhost:8080/auth/sso/callback` and the client ID and secret, then open `http://localhost:8080/login`. Use `localhost`, not `127.0.0.1`, for the reason in [Troubleshooting](#troubleshooting). To remove it afterwards: `docker rm -f pocket-id && docker volume rm pocket-id-data`. --- # Telemetry The server sends a small anonymous report every six hours. It says which version is running, on what kind of machine, and whether things are working. It never says who you are, what you track, or where you are. ```bash RUSTRAK_TELEMETRY=off # and nothing leaves, ever ``` ## Why it exists Rustrak is installed on machines we never see. Without a signal from them, "is 0.16 leaking memory for anyone?" and "does anyone still run SQLite on ARM?" are guesses. The report is what turns those into answers: which versions are alive, what they cost in memory, which routes throw errors, and which panics appear after a release. ## What is sent One `heartbeat` document, about 2 KB of JSON, every 6 hours. The first one leaves 10 minutes after startup, which is time enough to read the notice in the log and set the switch. This is the complete list of fields. ### Identity and build | Field | Example | What it is | |---|---|---| | `instance_id` | `f1c0…` | A random UUID minted the first time the server starts and stored in the database. Not derived from your hostname, IP, MAC address or anything else about the machine | | `version` | `0.16.0` | The server version | | `schema` | `1` | The report format version | | `os`, `arch` | `linux`, `x86_64` | Operating system and CPU architecture | | `container` | `true` | Whether the process runs in a container | | `db_backend`, `db_version` | `sqlite`, `3.46` | The database engine and its major.minor version | | `dashboard_served` | `true` | Whether the dashboard is mounted | | `uptime_secs` | `86412` | Seconds since the process started | | `first_since_boot` | `false` | Whether this is the first report of the process | ### Resources | Field | What it is | |---|---| | `cpu_count` | Logical CPUs available to the process | | `mem_total_mb` | Total memory of the host, rounded | | `rss_mb.{min,max,last}` | Memory the process held over the window, sampled every minute | | `sqlite_db_mb` | Size of the SQLite file, rounded. SQLite only | | `ingest_dir_pending` | Events waiting on disk to be digested at report time | ### Volume Every count below is blurred to two significant digits before it leaves: `4,517` events is reported as `4,500`, `123` projects as `120`. | Field | What it is | |---|---| | `projects`, `users`, `issues_open` | How many exist | | `events_24h`, `transactions_24h`, `sessions_24h`, `logs_24h` | How many arrived in the last 24 hours | | `github_projects`, `github_auto_create_projects` | How many projects have a GitHub repository connected, and how many of them open GitHub issues automatically. Never which repository | ### Health Counters since the previous report. This is the part that tells us what is breaking. | Field | What it is | |---|---| | `ingest.accepted` | Ingest requests answered 2xx | | `ingest.rejected.{rate_limit,auth,too_large,malformed,other}` | Ingest requests turned away, by reason | | `ingest.latency_ms.{p50,p99}` | Ingest latency percentiles, on a fixed scale of buckets. Measured from when the request body has arrived, so a slow client upload does not count | | `digest.ok`, `digest.failed` | Events grouped into issues, and events that could not be | | `digest.rate_limited` | Accepted events the quota dropped instead of storing | | `http_5xx_by_route` | Server errors per **route pattern**, such as `/api/issues/{id}`. The real path never appears | | `alerts_failed_by_provider` | Alert deliveries that failed, per provider kind (`slack`, `webhook`, …) | | `db_errors_by_code` | Database errors behind a server error or a failed digest, per error code alone: a SQLSTATE on PostgreSQL (`22001`), a result code on SQLite. The message, which can name tables and values, is never included | | `panics` | Panics per source location inside Rustrak's own code (`src/digest/mod.rs:212`). The panic message is never included, because it could carry a path or a query | ### Configuration Booleans and kinds only, never values. | Field | What it is | |---|---| | `ssl_proxy`, `public_url_set`, `smtp_configured`, `session_secret_set` | Whether each is set | | `alert_providers` | The kinds of alert channel enabled, such as `["slack", "webhook"]` | | `github_connected` | Whether a GitHub App is connected. Not which one, nor the account it is installed on | | `quota_customized` | Whether any `MAX_EVENTS_*` limit differs from its default. The limits themselves are not sent | ## What is never sent IP addresses (the receiving end discards them too), hostnames, `PUBLIC_URL`, `DATABASE_URL`, email addresses, project or team names, DSNs, event payloads, stack traces, error messages, request paths, release or environment names from your SDKs, panic messages, log lines. Nothing that came from an SDK, a user or a project ever leaves the machine. ## Where it goes To the Rustrak project's telemetry store, with IP storage and location lookups switched off. The maintainers see aggregate charts; there is no way to look up a machine from what is stored. ## How to see exactly what would be sent With a token whose user holds `telemetry:read`, as owners always do: ```bash curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/telemetry/preview ``` The response carries `enabled`, the reason if it is off, and `report`, the next heartbeat exactly as it would leave. It works whether telemetry is on or off, so the decision can be made on the real document. The same document is written to the log at `debug` level before every send. ## How to turn it off Either of these does it on its own: ```bash RUSTRAK_TELEMETRY=off DO_NOT_TRACK=1 # the cross-tool convention from consoledonottrack.com ``` `RUSTRAK_TELEMETRY` accepts `on`, `off`, `true`, `false`, `1` and `0`. Anything else refuses to start, deliberately: a typo must not be read as "on" when you meant to keep data from leaving the box. The startup log says which it is: ``` Anonymous telemetry is on (instance f1c0…). RUSTRAK_TELEMETRY=off disables it. https://rustrak.dev/configuration/telemetry ``` ``` Telemetry is off: RUSTRAK_TELEMETRY is off. ``` A binary built from source without the project's key (a fork, a local `cargo build`) has telemetry off as well, and says so: `Telemetry is off: no telemetry key was compiled into this binary.` ## Cost on your server One atomic increment per request, one small `/proc` read per minute, three count queries and one HTTP POST every six hours with a 10 second timeout. A failed send is a `debug` log line and is not retried; the next window carries on. An air-gapped installation loses nothing and logs nothing at `info` or above. ## Deleting what was sent The instance id is printed at startup and returned by the preview endpoint. It is a random value that says nothing about you, so it is safe to post: open an issue on [GitHub](https://github.com/rustrak/rustrak/issues) titled "Telemetry deletion" with the id, and the corresponding records are deleted. --- # @rustrak/client `@rustrak/client` is the official TypeScript client for the Rustrak REST API. It is generated from the server's OpenAPI spec, so it has one method for every endpoint the server documents, validates requests and responses with Zod, retries transient failures, and returns a `Result` that never throws for an expected failure. HTTP is the platform's `fetch`; the only runtime dependency is Zod. ```bash npm install @rustrak/client # or pnpm add @rustrak/client # or yarn add @rustrak/client ``` **Requirements**: Node.js ≥ 20, TypeScript ≥ 5 ## Quick Start Every method returns a `Result`: a plain object that is either `{ success: true, data }` or `{ success: false, error }`. Check `success` before reading `data`, and TypeScript narrows the rest. ```typescript import { RustrakClient } from '@rustrak/client'; const client = new RustrakClient({ baseUrl: 'https://your-rustrak-instance.example.com', token: process.env.RUSTRAK_API_TOKEN!, }); const projects = await client.projects.list(); if (!projects.success) { console.error(projects.error.kind); // 'unauthenticated' | 'network' | ... return; } console.log(projects.data.items); ``` The shape deliberately mirrors Zod's `safeParse`, and it is the same shape for every method. ## Configuration ```typescript const client = new RustrakClient({ baseUrl: 'https://rustrak.example.com', // required token: 'your-bearer-token', // create one in Settings → Tokens; omit to use the session cookie timeout: 30000, // optional, ms per attempt (default: 30000) maxRetries: 2, // optional (default: 2) headers: {}, // optional custom headers }); ``` ## Calling an endpoint Methods hang off the client by area, `client..`, and take one object holding the request's parts, named exactly as the API names them: ```typescript const issues = await client.issues.list({ path: { project_id: projectId }, query: { sort: 'last_seen', order: 'desc', per_page: 50 }, }); const resolved = await client.issues.update({ path: { project_id: projectId, issue_id: issueId }, body: { status: 'resolved' }, }); ``` Besides `path`, `query` and `body`, every method accepts `headers` and an `AbortSignal` as `signal`. The [API reference](#api-reference) below lists them all. ### Paginating ```typescript let cursor: string | undefined; do { const page = await client.events.list({ path: { project_id: projectId, issue_id: issueId }, query: { cursor }, }); if (!page.success) break; // a failed page is not an empty page process(page.data.items); cursor = page.data.next_cursor ?? undefined; } while (cursor); ``` That `break` is the point of the whole API. Stopping on a failure is a decision the caller has to make, and it can no longer be skipped by accident. ## Error Handling There are no error classes. Every failure is one member of a single closed union, discriminated by `kind`: ```typescript import { isRetryable, type RustrakError } from '@rustrak/client'; const result = await client.projects.list(); if (!result.success) { switch (result.error.kind) { case 'unauthenticated': redirect('/login'); // ONLY this kind means "log in again" break; case 'rate_limited': wait(result.error.retryAfter ?? 30); break; case 'network': // Branch on `reason`, not on `message`: the message is a fixed string by // design, because the underlying one names the host and port. console.log(result.error.reason); // 'timeout' | 'unreachable' break; default: if (isRetryable(result.error)) scheduleRetry(); } } ``` | `kind` | HTTP | Retryable | Description | |---|---|---|---| | `validation` | 400 | ❌ | The server rejected the request | | `unauthenticated` | 401 | ❌ | No session, or an invalid/expired token | | `forbidden` | 403 | ❌ | Authenticated, but not allowed | | `not_found` | 404 | ❌ | The resource does not exist | | `conflict` | 409 | ❌ | Uniqueness violation: a taken name or slug | | `gone` | 410 | ❌ | The resource was retired | | `payload_too_large` | 413 | ❌ | Envelope ingestion only | | `rate_limited` | 429 | ✅ | Rate limit exceeded; carries `retryAfter?: number` | | `client_error` | other < 500 | ❌ | Any sub-500 status no other member claims | | `server_error` | 500+ | ✅ | Any 5xx; the server's own message is discarded | | `invalid_request` | n/a | ❌ | Your input failed a pre-flight check; never sent | | `network` | n/a | ✅ | DNS, connection refused, TLS, timeout, abort | | `invalid_response` | n/a | ❌ | A 2xx arrived with a body the schema rejects | The union is closed, so a `switch` on `kind` with a `default` covers every case that exists. `status` is a plain `number` rather than a literal union, so a proxy-generated 502 or a future status is a value you can log rather than a type error. ### What the union will not carry | Dropped | Why | |---|---| | the server's 5xx message | It can interpolate a pool error, an OS errno, or a filesystem path. Replaced by the fixed `SERVER_ERROR_MESSAGE`. | | a `cause` on `network` | `fetch` builds that message by interpolating the request URL, which is your deployment's internal host and port. Read `reason` instead. | | Zod issues on `invalid_response` | They embed the offending response body. | ### Field errors A `4xx` that blamed specific inputs carries `fields`, so a form can mark the input the server rejected instead of showing a toast the user has to translate back into an edit: ```typescript const created = await client.projects.create({ body: { name: 'My App' } }); if (!created.success && created.error.kind === 'conflict') { for (const field of created.error.fields ?? []) { console.log(field.field, field.code); // e.g. 'slug' 'already_exists' } } ``` `field` is a **dot path into the request body** (`slug`, `credentials.webhook_url`), which is exactly what a form library's `setError` takes. Pick the copy you show from `(field, code)` rather than from `message`; that is what makes it translatable. `message` is populated only for `code: 'custom'`, where the code set genuinely cannot express the reason. One rule when wiring this to a form: **only call `setError` for names your form actually registers.** A path naming an input the form does not have registers a phantom field that nothing can ever clear, and the form then refuses to submit with nothing on screen explaining why. Route the rest to a form-level error. ### Escape hatches ```typescript import { unwrap, unwrapOr, mapResult } from '@rustrak/client'; ``` `unwrap` throws on a failure: the caller explicitly opting back into exceptions. `unwrapOr` substitutes a fallback. **Do not reach for `unwrapOr` to make a page compile.** `unwrapOr(await client.projects.list(), [])` renders the same empty state for "this account has no projects" and "the server is unreachable", which is exactly the regression this API exists to prevent. It is appropriate only where the fallback is genuinely correct regardless of why the call failed: a cached-count optimisation, a best-effort telemetry read, a script that already logged the error. ## Next.js Integration These examples are for your own Next.js application calling a Rustrak server through the client. `RUSTRAK_API_URL` and `RUSTRAK_API_TOKEN` are your app's settings: the server's address and an API token. A `Result` is a plain object with no prototype chain, so it survives `structuredClone` and therefore React's server/client boundary. A thrown error class does not: it reaches the browser as an opaque digest, which is why returning the failure is the recommended shape. ### Server Component ```typescript import { RustrakClient } from '@rustrak/client'; import { redirect } from 'next/navigation'; export default async function ProjectsPage() { const client = new RustrakClient({ baseUrl: process.env.RUSTRAK_API_URL!, token: process.env.RUSTRAK_API_TOKEN!, }); const projects = await client.projects.list(); if (!projects.success) { // `kind` decides. Redirecting on 'network' or 'server_error' turns a flaky // connection into a login loop that logging in cannot fix. if (projects.error.kind === 'unauthenticated') redirect('/login'); // your app's sign-in page return ; } return ; } ``` ### Server Action ```typescript 'use server'; import { RustrakClient } from '@rustrak/client'; export async function resolveIssue(projectId: number, issueId: string) { const client = new RustrakClient({ baseUrl: process.env.RUSTRAK_API_URL!, token: process.env.RUSTRAK_API_TOKEN!, }); // Returned as-is: the calling component gets the actual failure rather than // an "An error occurred in the Server Components render" digest. return client.issues.update({ path: { project_id: projectId, issue_id: issueId }, body: { status: 'resolved' }, }); } ``` ### Client Component with SWR ```typescript 'use client'; import useSWR from 'swr'; export function IssuesList({ projectId }: { projectId: number }) { // The fetcher never rejects, so SWR's `error` stays empty: the failure is in // `data`. Unwrap it in the fetcher if you want SWR's retry behaviour. const { data } = useSWR(['issues', projectId], () => client.issues.list({ path: { project_id: projectId } }), ); if (!data) return ; if (!data.success) return ; return ; } ``` ## TypeScript Types Every request and response type in the spec is exported under the name the spec gives it: ```typescript import type { ProjectResponse, IssueResponse, EventDetailResponse, IssuesListData, // what client.issues.list takes IssuesListResponse, // what it returns on success // the Result API Result, RustrakError, RustrakErrorKind, FieldError, FieldErrorCode, } from '@rustrak/client'; ``` Value exports for the same API: `Ok`, `Err`, `unwrap`, `unwrapOr`, `mapResult`, `isRetryable`, `FIELD_ERROR_CODES`, `SERVER_ERROR_MESSAGE`, `NETWORK_ERROR_MESSAGE`, `TIMEOUT_ERROR_MESSAGE`. ## API Reference Every method, grouped as it hangs off the client. {/* api:start */} {/* Generated by `pnpm --filter @rustrak/client generate` from openapi.json. Do not edit by hand. */} ### Access | Method | Endpoint | Description | |---|---|---| | `access.catalog()` | `GET /api/access/catalog` | Lists every resource a permission can name, with its module, scope and actions. | | `access.me()` | `GET /api/access/me` | Returns what the caller may do, on the instance and on each project. | | `roles.create({ body })` | `POST /api/roles` | Creates a role from permissions the caller holds themselves. | | `roles.delete({ path })` | `DELETE /api/roles/{id}` | Deletes a role nobody holds. | | `roles.list()` | `GET /api/roles` | Lists every role with its permissions and how often it is assigned. | | `roles.update({ path, body })` | `PUT /api/roles/{id}` | Replaces a role's name, description and permissions. | | `users.assignments({ path })` | `GET /api/users/{id}/assignments` | Lists the roles a user holds, on the instance and on each project. | | `users.setAssignments({ path, body })` | `PUT /api/users/{id}/assignments` | Replaces every role and personal permission a user holds. | ### Agents | Method | Endpoint | Description | |---|---|---| | `agents.conversation({ path })` | `GET /api/projects/{project_id}/agents/conversations/{conversation_id}` | Returns every AI span of a conversation's turns, oldest first, with its input and output. | | `agents.conversations({ path, query? })` | `GET /api/projects/{project_id}/agents/conversations` | Lists conversations -- traces sharing a `gen_ai.conversation.id` -- the most recently active first. | | `agents.duration({ path, query? })` | `GET /api/projects/{project_id}/agents/duration` | Time-bucketed avg/p95 duration for `agent`/`ai_client` spans. | | `agents.environments({ path })` | `GET /api/projects/{project_id}/agents/environments` | The options the environment filter offers, read from the data rather than hardcoded — an installation may name its environments anything. | | `agents.modelsCalls({ path, query? })` | `GET /api/projects/{project_id}/agents/models/calls` | Top models by LLM call count (`gen_ai.operation.type:ai_client`). | | `agents.modelsTable({ path, query? })` | `GET /api/projects/{project_id}/agents/models` | | | `agents.modelsTokens({ path, query? })` | `GET /api/projects/{project_id}/agents/models/tokens` | Top models by total tokens used (`gen_ai.operation.type:ai_client`). | | `agents.runs({ path, query? })` | `GET /api/projects/{project_id}/agents/runs` | Time-bucketed count of agent-run spans (`gen_ai.operation.type:agent`). | | `agents.summary({ path, query? })` | `GET /api/projects/{project_id}/agents/summary` | Agent runs, LLM calls, tool calls, errors, tokens and latency over the selected window — the numbers the six charts cannot state outright. | | `agents.tools({ path, query? })` | `GET /api/projects/{project_id}/agents/tools` | Top tools by call count (`gen_ai.operation.type:tool`). | | `agents.toolsTable({ path, query? })` | `GET /api/projects/{project_id}/agents/tools/stats` | | | `agents.traces({ path, query? })` | `GET /api/projects/{project_id}/agents/traces` | Paginated per-trace_id aggregate (duration, tokens, tool usage) across all AI spans sharing that trace, regardless of origin. | ### Alert Channels | Method | Endpoint | Description | |---|---|---| | `alertChannels.create({ body })` | `POST /api/integrations` | | | `alertChannels.delete({ path })` | `DELETE /api/integrations/{id}` | | | `alertChannels.get({ path })` | `GET /api/integrations/{id}` | | | `alertChannels.list()` | `GET /api/integrations` | Lists the channels alerts can go through. | | `alertChannels.previewTemplate({ body })` | `POST /api/integrations/preview-template` | Renders a Custom Webhook template against a sample payload and hands back the body that would be sent. | | `alertChannels.test({ path, body })` | `POST /api/integrations/{id}/test` | | | `alertChannels.update({ path, body })` | `PATCH /api/integrations/{id}` | | ### Alert Rules | Method | Endpoint | Description | |---|---|---| | `alertRules.create({ path, body })` | `POST /api/projects/{project_id}/alert-rules` | | | `alertRules.delete({ path })` | `DELETE /api/projects/{project_id}/alert-rules/{rule_id}` | | | `alertRules.get({ path })` | `GET /api/projects/{project_id}/alert-rules/{rule_id}` | | | `alertRules.historyByDay({ path, query? })` | `GET /api/projects/{project_id}/alert-history/daily` | Count a project's alert deliveries per day, by outcome. | | `alertRules.list({ path })` | `GET /api/projects/{project_id}/alert-rules` | | | `alertRules.listHistory({ path, query? })` | `GET /api/projects/{project_id}/alert-history` | | | `alertRules.update({ path, body })` | `PATCH /api/projects/{project_id}/alert-rules/{rule_id}` | | ### Auth | Method | Endpoint | Description | |---|---|---| | `auth.acceptInvitation({ body })` | `POST /auth/accept-invitation` | Accept a pending invitation: creates the user (with the invite's email + role) and logs in. | | `auth.changePassword({ body })` | `POST /auth/me/password` | Change the current user's password | | `auth.confirmSsoLink({ body })` | `POST /auth/sso/link` | Link the pending SSO identity to its account once the account's password is given, and sign in. | | `auth.getCurrentUser()` | `GET /auth/me` | Get current authenticated user | | `auth.getInvitation({ path })` | `GET /auth/invitation/{token}` | Returns the invitation's details if it is still acceptable (for the accept page). | | `auth.login({ body })` | `POST /auth/login` | Authenticate user and create session | | `auth.logout()` | `POST /auth/logout` | Clear session | | `auth.register({ body })` | `POST /auth/register` | Registration is invite-only — always rejected. | | `auth.ssoCallback({ query? })` | `GET /auth/sso/callback` | Validate the provider response, resolve or provision the local account, and establish the normal Rustrak session. | | `auth.ssoConfig()` | `GET /auth/sso/config` | Public, non-sensitive information used to render the login page. | | `auth.ssoLink()` | `GET /auth/sso/link` | The account an SSO login matched, shown while its owner confirms. | | `auth.ssoStart()` | `POST /auth/sso/start` | Create a one-time OIDC authorization request and retain its protections in the encrypted session cookie. | | `auth.updateCurrentUser({ body })` | `PATCH /auth/me` | Update the current user's presentation preferences | ### Events | Method | Endpoint | Description | |---|---|---| | `events.downloadAttachment({ path })` | `GET /api/projects/{project_id}/attachments/{attachment_id}` | Downloads an attachment. | | `events.get({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/events/{event_id}` | Gets a single event with full data | | `events.getBySentryId({ path })` | `GET /api/projects/{project_id}/events/sentry/{event_id}` | Resolve the ID returned by a Sentry SDK without already knowing the issue. | | `events.list({ path, query? })` | `GET /api/projects/{project_id}/issues/{issue_id}/events` | Lists events for an issue with cursor-based pagination | | `events.listAttachments({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/events/{event_id}/attachments` | Lists the files sent with an event, such as a feedback's screenshot. | ### GitHub | Method | Endpoint | Description | |---|---|---| | `github.callback({ query })` | `GET /api/github/callback` | GitHub sends the browser here after creating the app; the code trades for its credentials. | | `github.createIssue({ path, body })` | `POST /api/projects/{project_id}/issues/{issue_id}/github/issues` | Opens a GitHub issue and links it. | | `github.deleteProjectRepository({ path })` | `DELETE /api/projects/{project_id}/github` | | | `github.disconnect()` | `DELETE /api/github` | | | `github.getMyLogin()` | `GET /api/github/me` | The GitHub username assignees sync through. | | `github.getProjectRepository({ path })` | `GET /api/projects/{project_id}/github` | | | `github.installed({ query })` | `GET /api/github/installed` | GitHub sends the browser here after the app is installed on an account. | | `github.issue({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/github` | The issue's links, the commits that may have caused it, and who owns its culprit file. | | `github.issueDraft({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/github/draft` | What creating a GitHub issue sends by default, with the templates applied, for the person to edit first. | | `github.linkIssue({ path, body })` | `POST /api/projects/{project_id}/issues/{issue_id}/github/links` | Links an existing GitHub issue or pull request. | | `github.manifest({ body })` | `POST /api/github/manifest` | What the dashboard posts to GitHub so GitHub creates an app already configured for this server. | | `github.putIssueTemplate({ body })` | `PUT /api/github/issue-template` | The labels, title prefix and body footer every GitHub issue starts with, before a project's own. | | `github.putMyLogin({ body })` | `PUT /api/github/me` | | | `github.putProjectRepository({ path, body })` | `PUT /api/projects/{project_id}/github` | | | `github.repositories({ query })` | `GET /api/github/repositories` | The repositories a project can point at, for whoever may change that project's settings. | | `github.status()` | `GET /api/github` | | | `github.syncCodeowners({ path })` | `POST /api/projects/{project_id}/github/codeowners` | Copies the repository's CODEOWNERS so ownership can be answered without asking GitHub each time. | | `github.unlinkIssue({ path })` | `DELETE /api/projects/{project_id}/issues/{issue_id}/github/links/{link_id}` | | | `github.webhook({ body })` | `POST /api/github/webhook` | Public, and trusted only through the signature GitHub computes with the app's webhook secret. | ### Health | Method | Endpoint | Description | |---|---|---| | `health.liveness()` | `GET /health` | Liveness check - is the process running? | | `health.readiness()` | `GET /health/ready` | Readiness check - is the service ready to handle requests? | | `health.version()` | `GET /health/version` | Version check - returns the server version compiled from Cargo.toml. | ### Invitations | Method | Endpoint | Description | |---|---|---| | `invitations.create({ body })` | `POST /api/invitations` | Create an invitation (needs `invitations:create`) | | `invitations.emailStatus()` | `GET /api/invitations/email-status` | Whether invitation emails can be sent (needs `invitations:read`) | | `invitations.list()` | `GET /api/invitations` | List invitations (needs `invitations:read`) | | `invitations.resend({ path })` | `POST /api/invitations/{id}/resend` | Email the invite link again (needs `invitations:create`) | | `invitations.revoke({ path })` | `DELETE /api/invitations/{id}` | Revoke a pending invitation (needs `invitations:delete`) | ### Issues | Method | Endpoint | Description | |---|---|---| | `environments.list({ path })` | `GET /api/projects/{project_id}/environments` | Lists the environments a project's issues have been seen in, by name. | | `feedback.list({ path, query? })` | `GET /api/projects/{project_id}/feedback` | Lists user feedback with what each user submitted. | | `issues.bulkDelete({ path, body })` | `DELETE /api/projects/{project_id}/issues` | Bulk delete by ids or filter | | `issues.bulkUpdate({ path, body })` | `PUT /api/projects/{project_id}/issues` | Bulk mutate | | `issues.createComment({ path, body })` | `POST /api/projects/{project_id}/issues/{issue_id}/comments` | Add a note. | | `issues.createUserReport({ path, body })` | `POST /api/projects/{project_id}/issues/{issue_id}/user-reports` | Attach user feedback to an issue. | | `issues.delete({ path })` | `DELETE /api/projects/{project_id}/issues/{issue_id}` | Hard-deletes an issue and all associated events | | `issues.get({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}` | Gets a single issue by ID | | `issues.getActivity({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/activity` | Chronological activity log (incl. comments). | | `issues.getAggregates({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/aggregates` | Returns the unique user count and top tags for the issue. | | `issues.getHashes({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/hashes` | Lists the grouping hashes that map to an issue. | | `issues.getStats({ path, query? })` | `GET /api/projects/{project_id}/issues/{issue_id}/stats` | Zero-filled event-count timeseries. | | `issues.getTagValues({ path })` | `GET /api/projects/{project_id}/issues/{issue_id}/tags/{key}` | Lists the distinct values (with counts) for a tag key across the issue. | | `issues.list({ path, query? })` | `GET /api/projects/{project_id}/issues` | Lists issues for a project with offset-based pagination | | `issues.listUserReports({ path, query? })` | `GET /api/projects/{project_id}/issues/{issue_id}/user-reports` | List user feedback for an issue. | | `issues.markSeen({ path })` | `POST /api/projects/{project_id}/issues/{issue_id}/seen` | Mark the issue as seen by the current user. | | `issues.setBookmark({ path, body })` | `PUT /api/projects/{project_id}/issues/{issue_id}/bookmark` | Set/clear the per-user bookmark. | | `issues.setSubscription({ path, body })` | `PUT /api/projects/{project_id}/issues/{issue_id}/subscription` | Set/clear the per-user subscription. | | `issues.update({ path, body })` | `PATCH /api/projects/{project_id}/issues/{issue_id}` | Updates issue state (resolve, mute, etc.) | ### Logs | Method | Endpoint | Description | |---|---|---| | `logs.get({ path })` | `GET /api/projects/{project_id}/logs/{log_id}` | Returns one of a project's logs, wherever it falls in the list. | | `logs.list({ path, query? })` | `GET /api/projects/{project_id}/logs` | Lists logs for a project with offset-based pagination (newest first by log timestamp), optionally filtered by level, trace id, message and window. | | `logs.volume({ path, query? })` | `GET /api/projects/{project_id}/logs/volume` | Counts a project's logs per level over time, with the list's filters. | ### Members | Method | Endpoint | Description | |---|---|---| | `members.candidates({ path })` | `GET /api/projects/{project_id}/members/candidates` | Lists the people and the roles the caller can give on the project, so it takes neither `users:read` nor `roles:read` to manage its members. | | `members.list({ path })` | `GET /api/projects/{project_id}/members` | Lists everyone holding a role on the project, with those roles; personal permissions are not roles. | | `members.remove({ path })` | `DELETE /api/projects/{project_id}/members/{user_id}` | Removes the roles a user holds on this project; roles on all projects and personal permissions stay. | | `members.upsert({ path, body })` | `PUT /api/projects/{project_id}/members` | Sets the roles a user holds on the project. | ### Monitors | Method | Endpoint | Description | |---|---|---| | `monitors.create({ path, body })` | `POST /api/projects/{project_id}/monitors` | | | `monitors.delete({ path })` | `DELETE /api/projects/{project_id}/monitors/{slug}` | | | `monitors.get({ path })` | `GET /api/projects/{project_id}/monitors/{slug}` | | | `monitors.list({ path })` | `GET /api/projects/{project_id}/monitors` | | | `monitors.listCheckins({ path, query? })` | `GET /api/projects/{project_id}/monitors/{slug}/checkins` | | | `monitors.stats({ path, query? })` | `GET /api/projects/{project_id}/monitor-stats` | Counts the check-ins of a project's monitors by status over a window, bucket by bucket. | | `monitors.update({ path, body })` | `PUT /api/projects/{project_id}/monitors/{slug}` | | ### OAuth | Method | Endpoint | Description | |---|---|---| | `oauth.decideAuthorization({ body })` | `POST /api/oauth/authorize` | Approves or denies an authorization request as the signed-in user. | | `oauth.getAuthorization({ query })` | `GET /api/oauth/authorize` | Verifies an authorization request and says who is asking. | | `oauth.listGrants()` | `GET /api/oauth/grants` | Lists the assistants connected to the signed-in user's account. | | `oauth.revokeGrant({ path })` | `DELETE /api/oauth/grants/{id}` | Disconnects an assistant: its tokens stop working at once. | ### Projects | Method | Endpoint | Description | |---|---|---| | `projects.create({ body })` | `POST /api/projects` | Create a new project | | `projects.delete({ path })` | `DELETE /api/projects/{id}` | Delete a project | | `projects.get({ path })` | `GET /api/projects/{id}` | Get a project by ID | | `projects.getRateLimits()` | `GET /api/rate-limits` | What a project without limits of its own follows | | `projects.list({ query? })` | `GET /api/projects` | List projects with pagination | | `projects.update({ path, body })` | `PATCH /api/projects/{id}` | Update a project | ### Releases | Method | Endpoint | Description | |---|---|---| | `releases.create({ path, body })` | `POST /api/0/projects/{org_slug}/{project_slug}/releases/` | Sentry-compatible release creation. | | `releases.finalize({ path, body })` | `PUT /api/0/projects/{org_slug}/{project_slug}/releases/{version}/` | Generic partial update (`ref`, `url`, `dateReleased`). | | `releases.get({ path, query? })` | `GET /api/projects/{project_id}/releases/{release}` | Gets one release with its events, new issues, environments and session health. | | `releases.list({ path, query? })` | `GET /api/projects/{project_id}/releases` | Lists a project's releases, newest first, with the events, new issues, environments and session health of each. | | `releases.newIssues({ path, query? })` | `GET /api/projects/{project_id}/releases/{release}/new-issues` | | ### Schedules | Method | Endpoint | Description | |---|---|---| | `schedules.preview({ body })` | `POST /api/schedules/preview` | Validates a schedule and lists its next five runs from now, so no client ever parses a crontab itself. | ### Sentry API | Method | Endpoint | Description | |---|---|---| | `sentry.getIssue({ path })` | `GET /api/0/issues/{issue_id}/` | One issue with its releases and its 24h and 30d series (Sentry's `GroupDetailsEndpoint`). | | `sentry.getLatestEvent({ path })` | `GET /api/0/issues/{issue_id}/events/latest/` | The issue's most recent event (Sentry's `GroupEventDetailsEndpoint`, `issues/endpoints/group_event_details.py`). | | `sentry.listIssues({ path, query? })` | `GET /api/0/projects/{org_slug}/{project_slug}/issues/` | A project's issues, filtered by `query`, sorted by `sort` and paginated by `cursor` (Sentry's `ProjectGroupIndexEndpoint`, `issues/endpoints/project_group_index.py`). | | `sentry.listOrganizations()` | `GET /api/0/organizations/` | The organizations the token belongs to (Sentry's `OrganizationIndexEndpoint`): always the one synthetic org. | | `sentry.listProjects({ query? })` | `GET /api/0/projects/` | The projects the token can see, newest first (Sentry's `ProjectIndexEndpoint`, `core/endpoints/project_index.py`). | | `sentry.listRepos({ path })` | `GET /api/0/organizations/{org_slug}/repos/` | The code repositories connected to the organization (Sentry's `OrganizationRepositoriesEndpoint`). | | `sentry.openIssue({ path })` | `GET /{org_slug}/{project_slug}/issues/{issue_id}` | The issue page GitLab's "View in Sentry" opens: Sentry redirects this legacy path to the issue (`static/app/router/routes.tsx`), Rustrak to its dashboard. | | `sentry.submitUserFeedback({ path, body })` | `POST /api/0/projects/{org_slug}/{project_slug}/user-feedback/` | Submit a user report about an error event (Sentry's `ProjectUserReportsEndpoint`, `feedback/endpoints/project_user_reports.py`). | | `sentry.updateIssue({ path, body })` | `PUT /api/0/issues/{issue_id}/` | Sets an issue's status and returns the issue (Sentry's `GroupDetailsEndpoint.put`). | ### Sessions | Method | Endpoint | Description | |---|---|---| | `sessions.getStats({ path, query? })` | `GET /api/projects/{project_id}/sessions/stats` | | | `sessions.getSummary({ path, query? })` | `GET /api/projects/{project_id}/sessions/summary` | | | `sessions.getTimeseries({ path, query? })` | `GET /api/projects/{project_id}/sessions/timeseries` | | ### Settings | Method | Endpoint | Description | |---|---|---| | `settings.getMcp()` | `GET /api/settings/mcp` | Whether the MCP endpoint at `/mcp` is on. | | `settings.updateMcp({ body })` | `PUT /api/settings/mcp` | Turns the MCP endpoint at `/mcp` on or off (needs `mcp:update`). | ### Source Maps | Method | Endpoint | Description | |---|---|---| | `debugFiles.assemble({ path, body })` | `POST /api/0/projects/{org_slug}/{project_slug}/files/difs/assemble/` | Assemble uploaded chunks into debug information files (ProGuard mappings, JVM source bundles). | | `debugFiles.upload({ path, body })` | `POST /api/0/projects/{org_slug}/{project_slug}/files/dsyms/` | Upload a zip of debug information files (the path `sentry-cli upload-proguard` takes). | | `sourceMaps.artifactBundleAssemble({ path, body })` | `POST /api/0/organizations/{org_slug}/artifactbundle/assemble/` | | | `sourceMaps.chunkUpload({ path, body })` | `POST /api/0/organizations/{org_slug}/chunk-upload/` | | | `sourceMaps.chunkUploadCapability({ path })` | `GET /api/0/organizations/{org_slug}/chunk-upload/` | | | `sourceMaps.list({ path })` | `GET /api/0/projects/{org_slug}/{project_slug}/files/source-maps/` | | | `sourceMaps.orgDetails({ path })` | `GET /api/0/organizations/{org_slug}/` | Always returns 200 with synthetic org data — sentry-cli validates this before uploading. | ### Spans | Method | Endpoint | Description | |---|---|---| | `spans.get({ path })` | `GET /api/projects/{project_id}/spans/{span_id}` | Returns one span together with its attributes and tags — the `gen_ai.*` payload (prompts, responses, tool arguments and results) that the list response deliberately omits, since `spans.data` is untrimmed and a whole trace's worth of it would dwarf the waterfall it is drawn from. | | `spans.list({ path, query? })` | `GET /api/projects/{project_id}/spans` | Lists spans for a project with offset-based pagination (newest by start_timestamp first), optionally filtered by op/status/trace_id. | ### Stats | Method | Endpoint | Description | |---|---|---| | `stats.getEventTimeseries({ path, query? })` | `GET /api/projects/{project_id}/events/stats` | | | `stats.getSummary({ path, query? })` | `GET /api/projects/{project_id}/stats/summary` | | ### Storage | Method | Endpoint | Description | |---|---|---| | `storage.compact()` | `POST /api/storage/compaction` | Rewrites a SQLite file created before incremental mode, so it gives freed space back from then on. | | `storage.executeCleanup({ body })` | `POST /api/storage/cleanup` | Starts deleting old data and the issues it empties. | | `storage.gcSourceMaps()` | `POST /api/storage/source-maps/gc` | Delete orphaned source maps (DB + disk). | | `storage.getAge()` | `GET /api/storage/age` | Rows per age bucket, per project and kind. | | `storage.getCleanupStatus()` | `GET /api/storage/cleanup/status` | Progress of the running cleanup, or the outcome of the last one. | | `storage.getCompaction()` | `GET /api/storage/compaction` | Whether the database file shrinks when data is deleted, and the disk a one-off compaction needs. | | `storage.getProjects()` | `GET /api/storage/projects` | Per-project counts and estimated weight. | | `storage.getRetention()` | `GET /api/storage/retention` | | | `storage.getSummary()` | `GET /api/storage/summary` | Row counts, DB size, and source-map weight. | | `storage.previewCleanup({ body })` | `POST /api/storage/cleanup/preview` | Dry-run count, mutates nothing. | | `storage.previewRetention({ body })` | `POST /api/storage/retention/preview` | What saving these settings would delete on the next cleanup, without saving or deleting anything. | | `storage.previewSourceMapGc()` | `POST /api/storage/source-maps/gc/preview` | Dry-run orphan count, deletes nothing. | | `storage.runRetention()` | `POST /api/storage/retention/run` | Enforces the saved retention now, without waiting for the schedule. | | `storage.updateRetention({ body })` | `PUT /api/storage/retention` | | ### Team | Method | Endpoint | Description | |---|---|---| | `team.deleteUser({ path })` | `DELETE /api/team/{user_id}` | Remove a user from the instance (needs `users:delete`) | | `team.list()` | `GET /api/team` | List all users (needs `users:read`) | | `team.updateRole({ path, body })` | `PATCH /api/team/{user_id}/role` | Change a user's global role (needs `users:update`) | ### Telemetry | Method | Endpoint | Description | |---|---|---| | `telemetry.preview()` | `GET /api/telemetry/preview` | | ### Tokens | Method | Endpoint | Description | |---|---|---| | `tokens.create({ body })` | `POST /api/tokens` | Create a new token (from a signed-in session only) | | `tokens.delete({ path })` | `DELETE /api/tokens/{id}` | Revoke a token | | `tokens.get({ path })` | `GET /api/tokens/{id}` | Token details, masked | | `tokens.list()` | `GET /api/tokens` | List tokens (masked) | ### Transactions | Method | Endpoint | Description | |---|---|---| | `transactions.get({ path })` | `GET /api/projects/{project_id}/transactions/{transaction_id}` | Returns a single transaction with its full Sentry payload (spans, contexts, measurements, tags) for the performance detail view. | | `transactions.getSpans({ path })` | `GET /api/projects/{project_id}/transactions/{transaction_id}/spans` | Returns the indexed spans extracted from a transaction, ordered for the waterfall view (by start_timestamp ascending). | | `transactions.getStatGroup({ path, query })` | `GET /api/projects/{project_id}/transactions/stats/group` | Returns the aggregate stats for a single transaction group — a direct lookup so the summary header works regardless of how many groups the project has. | | `transactions.getStatGroupTimeseries({ path, query })` | `GET /api/projects/{project_id}/transactions/stats/group/timeseries` | Returns one endpoint's trend over the window, every bucket present: how often it ran and failed, and its p50 and p95. | | `transactions.getStats({ path, query? })` | `GET /api/projects/{project_id}/transactions/stats` | Returns paginated aggregate performance stats (count, time spent, p50/p95/p99 latency, failure rate) grouped by transaction name + op, the most time spent first unless `sort` asks otherwise. | | `transactions.list({ path, query? })` | `GET /api/projects/{project_id}/transactions` | Lists transactions for a project with offset-based pagination (newest first). | {/* api:end */} ## Related - [MCP server](https://rustrak.dev/sdks/mcp): built into the Rustrak server; gives Claude, Cursor and other MCP clients direct access to your instance - [API Reference](https://rustrak.dev/api-reference): Full REST API documentation - [API Tokens](https://rustrak.dev/usage/tokens): How to create and manage tokens --- # MCP server Rustrak has a built-in [Model Context Protocol](https://modelcontextprotocol.io) server. Turn it on and Claude, Cursor, VS Code or any other MCP client can read and triage your errors with 79 tools. There is nothing to install: the client connects to your Rustrak instance at `/mcp`. ``` ┌─────────────────────┐ Streamable HTTP ┌─────────────────────┐ │ AI client │ ───────────────────▶ │ Rustrak server │ │ (Claude, Cursor…) │ POST /mcp │ /mcp → same │ └─────────────────────┘ Bearer │ handlers as /api │ └─────────────────────┘ ``` Every tool runs the same handler as the matching REST endpoint, so it has the same permission checks, the same validation and the same response shape as the [API](https://rustrak.dev/api-reference). > Replacing `@rustrak/mcp`: the npm package is deprecated. The tools keep their names, so swapping the client configuration below for your old `npx @rustrak/mcp` entry is the whole migration. ## Setup ### 1. Turn it on In the dashboard, open **Settings → AI assistants (MCP)** and switch the MCP server on. This takes `mcp:update`, which owners always hold, and it is off by default: until someone turns it on, `/mcp` answers 404. The same page shows your endpoint URL and the configuration snippets below, already filled in. ### 2. Connect your assistant There are two ways in, and both act as one user: the assistant can do what that user can, and nothing more. A member only reaches what [their roles](https://rustrak.dev/usage/roles) allow; an owner can do anything, including the destructive tools. An API token can narrow that further; an OAuth connection cannot. #### Web assistants: sign in and approve (claude.ai, ChatGPT) Add a custom connector with your endpoint URL, `https://your-rustrak.example.com/mcp`, and nothing else. The assistant opens Rustrak, you sign in (SSO included) and approve it, and it is connected. No token to copy. Under the hood this is the OAuth 2.1 flow the MCP specification asks for, with Rustrak as its own authorization server: the assistant identifies itself with a [Client ID Metadata Document](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration), proves its request with PKCE, and gets an access token that lasts an hour and works only at `/mcp`. Its refresh token works once: every refresh replaces both. Every connection is listed under **Settings → AI assistants (MCP) → Connected assistants**, where disconnecting one cuts it off at once. A refresh token presented twice means it was stolen, and ends the connection for whoever holds it. > **Signing in needs `PUBLIC_URL`**, set to the address assistants reach Rustrak on (for example `https://rustrak.example.com`). The authorization server and the resource the tokens are bound to are both named after it, never after the request or the bind address. Without it there is no sign-in to discover, and the settings page says so; API tokens still work. The consent screen is a dashboard page, served at `DASHBOARD_URL` when the dashboard runs on its own host, so an API-only deployment connects with tokens. #### Desktop and CLI clients: an API token (Claude Code, Cursor, VS Code) Create a token in **Settings → API Tokens** (see [API Tokens](https://rustrak.dev/usage/tokens)) and pass it as a header. **Claude Code** ```bash claude mcp add --transport http rustrak https://your-rustrak.example.com/mcp \ --header "Authorization: Bearer your-40-char-token" ``` **Cursor** (`~/.cursor/mcp.json`) and other clients that read `mcpServers`: ```json { "mcpServers": { "rustrak": { "type": "http", "url": "https://your-rustrak.example.com/mcp", "headers": { "Authorization": "Bearer your-40-char-token" } } } } ``` **VS Code** (`.vscode/mcp.json`, or **MCP: Open User Configuration**). VS Code asks for the token the first time and keeps it encrypted, so the file holds no secret and can be committed: ```json { "inputs": [ { "type": "promptString", "id": "rustrak-token", "description": "Rustrak API token", "password": true } ], "servers": { "rustrak": { "type": "http", "url": "https://your-rustrak.example.com/mcp", "headers": { "Authorization": "Bearer ${input:rustrak-token}" } } } } ``` `"type": "http"` matters: a client that reads a URL without it, such as Claude Code's `.mcp.json`, takes the entry for a local stdio server. The settings page shows these snippets with your own URL filled in. ## How it works - **Stateless.** `/mcp` speaks protocol version `2026-07-28` over Streamable HTTP. Every call is one `POST` that carries its own token, and the server keeps nothing between calls, so the endpoint costs nothing while idle. - **Bearer only.** `/mcp` takes an OAuth access token or an API token. The dashboard's session cookie is not accepted, so a browser tab cannot call tools for its user, and an OAuth token is not accepted anywhere but `/mcp`. - **Errors are results.** A failed call comes back as tool output with `isError: true`, so the assistant reads what went wrong instead of losing the call. - **Destructive tools are marked** with `destructiveHint`, and clients that support it ask before running them. The storage cleanup tools also require an explicit `confirm=true`. ## Available tools ### Projects | Tool | Description | |---|---| | `list_projects` | Projects you have access to | | `get_project` | One project, with its DSN | | `create_project` | Create a project | ### Issues | Tool | Description | |---|---| | `list_issues` | Issues of a project, filtered by state or free text | | `get_issue` | One issue with its details | | `resolve_issue` / `unresolve_issue` | Resolve or re-open an issue | | `mute_issue` | Mute an issue so it stops alerting | | `update_issue_status` | Set status (unresolved, resolved, ignored, resolvedInNextRelease) and priority | | `assign_issue` | Assign an issue, or clear the assignment | | `bulk_update_issues` | Set status or priority on many issues | | `delete_issue` | ⚠️ Delete an issue and its events | | `bulk_delete_issues` | ⚠️ Delete issues by id or by state | | `get_issue_hashes` | Grouping hashes that map to an issue | | `get_issue_tag_values` | Values of one tag across an issue | | `get_issue_aggregates` | Affected users and top tags | | `get_issue_stats` | Event count over 24h or 30d | | `get_issue_activity` | Status changes and comments | | `comment_on_issue` | Add a comment | | `bookmark_issue` / `subscribe_issue` / `mark_issue_seen` | Your bookmark, subscription and seen state | | `list_user_reports` / `submit_user_report` | User feedback attached to an issue | | `list_feedback` | A project's user feedback, with what each user submitted | ### Events | Tool | Description | |---|---| | `list_events` | Occurrences of an issue | | `get_event` | One event with its full Sentry payload and stack trace | | `get_attachment` | A file sent with an event, such as a feedback's screenshot: images come back as images | ### Performance, logs and AI agents | Tool | Description | |---|---| | `list_transactions` / `get_transaction` | Transactions and one transaction's payload | | `get_transaction_stats` | Throughput, p50/p95/p99 and failure rate per transaction | | `get_transaction_spans` | Spans of a transaction in waterfall order | | `list_spans` | Spans of a project, filterable by op, status, trace or AI operation | | `list_logs` | Logs, filterable by level or trace | | `get_agent_runs` / `get_agent_duration` | AI agent run counts and durations over time | | `get_agent_models_by_calls` / `get_agent_models_by_tokens` / `get_agent_tools` | Top models and tools | | `list_agent_traces` | AI agent traces with duration, tokens and tool calls | ### Health, stats and crons | Tool | Description | |---|---| | `get_error_volume` | Error events over time by severity | | `get_project_stats` | Headline counters against the previous window | | `get_release_health` | Crash-free sessions and users per release | | `list_monitors` / `get_monitor` / `list_monitor_checkins` | Cron monitors and their check-ins | | `create_monitor` / `update_monitor` | Create, reconfigure or mute a monitor | | `delete_monitor` | ⚠️ Delete a monitor and its check-ins | | `preview_schedule` | Validate a crontab or interval and list its next runs | | `get_server_version` | The server's version | ### Alerts | Tool | Description | |---|---| | `list_alert_channels` | Notification channels | | `create_alert_channel` / `update_alert_channel` | Create or change a channel | | `preview_alert_template` | Render a custom webhook body against a sample alert | | `test_alert_channel` | Send a test notification | | `list_alert_rules` | Alert rules of a project | ### Team, tokens and storage Each tool needs the permission that its description names, such as `storage:read`, `invitations:create` or `project-members:update`. Token tools work on the caller's own tokens; `api-tokens:read` and `api-tokens:delete` also reach everyone else's. See [Team & Access](https://rustrak.dev/usage/team). | Tool | Description | |---|---| | `list_team_members` / `update_member_role` | Users and their global role | | `remove_team_member` | ⚠️ Remove a user from the instance | | `create_invitation` / `list_invitations` | Invite by email | | `revoke_invitation` | ⚠️ Revoke a pending invitation | | `list_project_members` / `set_project_member` | A project's members and their roles | | `remove_project_member` | ⚠️ Remove a user from a project | | `list_tokens` / `get_token` | API tokens | | `revoke_token` | ⚠️ Revoke a token | | `get_storage_summary` / `get_storage_by_project` | What is using storage | | `preview_storage_cleanup` / `execute_storage_cleanup` | ⚠️ Delete data older than N days, with `confirm=true` | | `get_storage_cleanup_status` | Progress of the running cleanup | | `preview_storage_source_maps_gc` / `gc_storage_source_maps` | ⚠️ Remove orphaned source maps, with `confirm=true` | | `get_retention` / `preview_retention` | Retention settings and what they would delete | | `update_retention` / `run_retention` | ⚠️ Save or enforce retention, with `confirm=true` | > Tools marked ⚠️ are annotated with `destructiveHint`. ## Example prompts - *"List the unresolved issues in project 1"* - *"Show me the stack trace of the latest event of issue abc-123"* - *"Which transactions got slower this week?"* - *"Resolve every TypeError issue from the last release"* - *"Invite alice@example.com as a member and give me the invite link"* ## Security notes - Error events are written by the applications you monitor, so their text can be anything, including instructions aimed at an assistant. The server tells clients to treat event content as data, but review what an assistant proposes before letting it run destructive tools. - Give an assistant the least privileged token that does the job: [limit it](https://rustrak.dev/usage/tokens#create-a-token) to the permissions and projects it needs. A token never exceeds its owner, and the MCP server cannot create tokens. - Disconnecting the assistant, revoking its token, disabling its user or turning the switch off cuts it off at once. - The consent screen cannot be framed by another site, and an authorization request whose client or redirect Rustrak cannot verify is refused without sending the browser anywhere. ## Related - [`@rustrak/client`](https://rustrak.dev/sdks/client): the TypeScript client for the same API - [API Tokens](https://rustrak.dev/usage/tokens): creating and revoking tokens - [API Reference](https://rustrak.dev/api-reference): the REST endpoints behind every tool --- # Common Issues Quick solutions to the most frequent problems. ## DSN shows `0.0.0.0` instead of your domain If your DSN looks like `http://key@0.0.0.0:8080/1`, the server is using its TCP bind address instead of your public URL. **Fix:** Set `PUBLIC_URL` in your server environment: ```bash PUBLIC_URL=https://rustrak.yourcompany.com ``` The DSN will immediately reflect the correct URL on the next API call. See [Environment Variables](https://rustrak.dev/configuration/environment#public_url) for details. ## Events not appearing ### Check your DSN The DSN format is: ``` http://@:/ ``` Make sure: - The host is reachable from your app (set `PUBLIC_URL` if it shows `0.0.0.0`) - The port is correct (default: 8080) - The project ID exists ### Enable SDK debug mode ```javascript Sentry.init({ dsn: 'YOUR_DSN', debug: true, // Logs SDK activity to console }); ``` ### Check network connectivity ```bash # Test from your app's server/machine curl -v http://your-rustrak-server:8080/health ``` You should see `{"status":"ok"}`. ### Check rate limits If you're hitting rate limits, events are dropped. Check server logs: ```bash docker logs rustrak ``` Look for "rate limit exceeded" messages. ## CORS errors The server already answers CORS for any origin, so a browser SDK on any domain can send events and the preflight passes. The dashboard needs no CORS at all: it calls the API on the origin it was loaded from. **Don't add CORS headers at your reverse proxy.** The browser then sees `Access-Control-Allow-Origin` twice and rejects the response. If you see CORS errors behind a proxy, check that it passes the `OPTIONS` request and the `X-Sentry-Auth`, `sentry-trace` and `baggage` headers through to the server unchanged. ## Can't connect to dashboard The dashboard is served by the server itself, from `/`, so its address is the server's address: `http://localhost:8080` by default, or your `PUBLIC_URL`. The sign-in page is `/login`. If a page answers with a JSON 404 instead of the dashboard, the startup log says why. It prints one of these lines: ``` Serving the dashboard from /app/static Dashboard switched off by RUSTRAK_DASHBOARD — serving the API only No dashboard build at ./static — serving the API only ``` - **Switched off**: `RUSTRAK_DASHBOARD=off` is set. Remove it to serve the dashboard again. - **No dashboard build**: the server was built without one, for example a plain `cargo build`. The published images always include it. - **`/auth/login`**: that was the sign-in address before 0.15. It now belongs to the API and answers a JSON 404; open `/login` instead. Also check that: - You are opening the server's port (`8080` by default) - `SSL_PROXY=true` is set when a proxy terminates HTTPS, or the session cookie is dropped and every page looks signed out - No firewall blocks the connection ### Server not responding Check if the server is running: ```bash docker ps | grep rustrak curl http://localhost:8080/health ``` ## Database connection errors ### Check DATABASE_URL ```bash # Format postgres://username:password@host:port/database # Common mistakes postgres://user:pass@localhost:5432/rustrak # Wrong if using Docker postgres://user:pass@postgres:5432/rustrak # Correct for Docker Compose ``` ### Database not ready The server might start before PostgreSQL is ready. Add a healthcheck: ```yaml services: server: depends_on: postgres: condition: service_healthy ``` ## Authentication errors ### 401 Unauthorized (API) Check your token: - Format: 40 hexadecimal characters - Header: `Authorization: Bearer YOUR_TOKEN` - Token exists and hasn't been deleted ### Can't log in to dashboard 1. Check that the first user was created. `CREATE_SUPERUSER=email:password` creates an admin only when the database has no users yet; the startup log says `Superuser created`, or that it skipped creation because users exist. Changing it later does not change an existing account's password. 2. Check the email and password. The email is not case-sensitive. 3. Clear the browser's cookies for the Rustrak address and try again. There is no password reset in the dashboard yet. If another admin can sign in, they can remove the account under **Settings → Members** and invite it again. ## High memory usage ### Server using too much RAM Check your rate limits. Processing many events increases memory: ```bash # Reduce limits for constrained environments MAX_EVENTS_PER_MINUTE=100 MAX_EVENTS_PER_HOUR=1000 ``` ### The dashboard is not the cost The dashboard is static files the server reads once at startup, so it adds nothing to memory under load. The whole install idles under 100 MB. If you don't want it anyway, `RUSTRAK_DASHBOARD=off` serves the API only. ## Events delayed Events go through two phases: 1. **Ingest** (immediate): Event received and stored 2. **Digest** (background): Event processed and grouped If events appear delayed, the digest phase might be backed up. Check server logs for errors. ## Stack traces still show minified code If errors show minified file names instead of your real source files, the source maps aren't reaching Rustrak. Source map upload is a **separate** channel from error reporting: your DSN sends **events** to Rustrak, but the build plugin decides where **source maps** go. The two most common causes: 1. **The upload URL isn't set** → the Sentry build tooling defaults to its hosted service (`sentry.io`). You must set the plugin's `url` option to your Rustrak server. Pointing the DSN at Rustrak is not enough. 2. **No auth token** → without `authToken`, the upload is **silently skipped** and the build still succeeds. Verify whether anything was actually uploaded for your project: ```bash curl -H "Authorization: Bearer YOUR_TOKEN" \ http://localhost:8080/api/0/projects/x/your-project/files/source-maps/ ``` An empty `data` array means no source maps exist for that project yet. See [Source Maps](https://rustrak.dev/usage/source-maps) for full setup. ## Still stuck? - Check server logs: `docker logs rustrak` - Enable debug logging: `RUST_LOG=debug` - [Open an issue](https://github.com/rustrak/rustrak/issues) --- # FAQ ## General ### What SDKs work with Rustrak? Any Sentry SDK. Rustrak implements the Sentry protocol, so you can use the official Sentry SDKs for JavaScript, Python, Go, Rust, Java, .NET, PHP, Ruby, and more. ### What does Rustrak cover? - **Errors**: grouping into [issues](https://rustrak.dev/usage/issues), stack traces with [source maps](https://rustrak.dev/usage/source-maps) and [debug files](https://rustrak.dev/usage/debug-files), and [alerts](https://rustrak.dev/usage/alerts) - **[Performance](https://rustrak.dev/usage/performance)**: transactions and spans - **[Releases](https://rustrak.dev/usage/releases)**: release health from SDK sessions - **[Logs](https://rustrak.dev/usage/logs)**, **[Crons](https://rustrak.dev/usage/crons)**, **[user feedback](https://rustrak.dev/usage/user-feedback)** and **[AI agent](https://rustrak.dev/usage/agents) traces** It does not support session replays or profiling. ### How much resources does Rustrak need? One container of about 20 MB, dashboard included, that idles under 100 MB of memory. A 512 MB VPS is enough for a small team. The dashboard is static files, so it adds nothing under load. ### Can I migrate from Sentry? Yes: change your DSN. Your SDK code stays the same: ```javascript // Before Sentry.init({ dsn: 'https://key@sentry.io/123' }); // After Sentry.init({ dsn: 'http://key@your-server:8080/1' }); ``` Historical data doesn't migrate; you start fresh. ## Deployment ### Do I need to run the dashboard separately? No. The server serves it at `/`, on the same port as the API, so there is nothing else to run. Two variations exist for special cases: - **API only**: set `RUSTRAK_DASHBOARD=off` and the server serves no pages. - **Dashboard on another host**: run `rustrak/rustrak-ui` there. See [Dashboard on its own host](https://rustrak.dev/configuration/production#dashboard-on-its-own-host). ### Can I run Rustrak without Docker? Yes. Build from source, from the repository root: ```bash pnpm install pnpm build --filter=@rustrak/server # builds the dashboard, then the server cd apps/server DATABASE_URL=sqlite://./rustrak.db \ SOURCEMAP_STORAGE_PATH=./data/sourcemaps \ ./target/release/rustrak ``` This build uses SQLite. Without `DATABASE_URL` the database goes to `/data/rustrak.db`, the path the Docker image uses, which a machine without a writable `/data` cannot create; set it, and `SOURCEMAP_STORAGE_PATH`, to a local path as above. For PostgreSQL, build with `cargo build --release --no-default-features --features postgres`. A plain `cargo build` works too, without Node, and gives a server with no dashboard. ### How do I back up my data? Back up the database and the source maps. See [Backups](https://rustrak.dev/configuration/production#backups) for SQLite and PostgreSQL. ## Data ### How long are events stored? Until you say otherwise: by default everything is kept. An admin sets how long errors, transactions and logs are kept under **Settings → Storage**, and a scheduled cleanup deletes what is older. Old data can also be deleted by hand there. See [Storage](https://rustrak.dev/usage/storage). ### Can I export my data? Yes, via the API: ```bash # Get all issues curl -H "Authorization: Bearer TOKEN" http://localhost:8080/api/projects/1/issues # Get events for an issue curl -H "Authorization: Bearer TOKEN" http://localhost:8080/api/projects/1/issues/ISSUE_ID/events ``` ### Is my data encrypted? - **In transit**: Use HTTPS (configure via reverse proxy) - **At rest**: Depends on your disk or PostgreSQL setup Rustrak doesn't add additional encryption. ## Troubleshooting ### Why are my errors grouped incorrectly? Rustrak groups by exception type, message, and transaction. Use custom fingerprints for better control: ```javascript Sentry.captureException(error, { fingerprint: ['my-custom-group'], }); ``` ### Why don't I see stack traces? Check that your SDK is configured to send them. For minified JavaScript, upload [source maps](https://rustrak.dev/usage/source-maps). ### Can I ignore certain errors? Use your SDK's `beforeSend` hook: ```javascript Sentry.init({ dsn: 'YOUR_DSN', beforeSend(event) { if (event.message?.includes('Expected error')) { return null; // Don't send } return event; }, }); ``` Or [archive](https://rustrak.dev/usage/issues#resolve-archive-reopen) the issue in the dashboard. ## Features ### Does Rustrak support alerts? Yes. New issues, regressions and user feedback can notify Slack, email, a webhook, or a custom webhook whose JSON body you write, for services with their own message format. See [Alerts](https://rustrak.dev/usage/alerts). ### Is there multi-user support? Yes. You invite people and give them roles built from granular permissions, on all projects or on the ones you choose. See [Team & Access](https://rustrak.dev/usage/team) and [Roles & Permissions](https://rustrak.dev/usage/roles). ### Is there multi-organization support? No. Rustrak is designed for single-team use. For multiple organizations, run separate instances. --- # Upgrading Most Rustrak releases need nothing from you: pull the new image and restart. The ones that do need something get a guide of their own, listed below. Read every guide between the version you run and the one you are moving to, in order. ## Upgrade guides | From | To | What needs your attention | Guide | |---|---|---|---| | 0.14.x | 0.15.x | The dashboard container goes away, telemetry arrives, going back needs a backup | [Upgrade to 0.15](https://rustrak.dev/upgrading/0-15) | | 0.24.x | 0.25.x | Roles replace project levels, members need a permission to create projects, tokens are hashed and cannot create tokens | [Upgrade to 0.25](https://rustrak.dev/upgrading/0-25) | ## For API client and MCP users Two earlier releases changed what code that talks to Rustrak uses, and 0.25 changes the member and token endpoints (see [its guide](https://rustrak.dev/upgrading/0-25#other-api-changes)). Only the MCP change needs a step on the server: an owner turns the MCP server on, since it starts off. - **0.19**: `@rustrak/client` is generated from the server's OpenAPI spec. Every method takes one `{ path, query, body }` object, and several methods and types were renamed. See [@rustrak/client](https://rustrak.dev/sdks/client). - **0.19**: the MCP server moved inside Rustrak, at `/mcp`. The separate `@rustrak/mcp` package is no longer published; see [MCP server](https://rustrak.dev/sdks/mcp) to connect an assistant to the built-in one. ## Versions you cannot skip None so far. Any 0.x release can be upgraded to any later one directly, as long as you follow each guide in between. If that ever changes, the version you have to stop at will be listed here and in its release notes. ## How versions work Rustrak is on `0.x`, and follows the usual reading of semver for it: - **A patch release (`0.15.1`)** fixes things. It never needs a step from you and never has a guide. - **A minor release (`0.16.0`)** can change behaviour, configuration or deployment. If it does, it has a guide here, and its release notes open with what needs your attention. The server, the dashboard and `@rustrak/client` always share one version number. A client and a server with the same number are known to work together. ## Before any upgrade 1. **Back up.** Database migrations only run forward, so the backup is what lets you go back. See [Backups](https://rustrak.dev/configuration/production#backups). 2. **Pin the version.** Use a versioned image tag such as `rustrak/rustrak-server:v0.15.0` in production rather than `latest`, so a restart never upgrades you by surprise. 3. **Read the guides** between your version and the target. 4. **Upgrade, then check** that the server started, that you can sign in, and that events still arrive. **Settings → About** shows the running version. ## Going back Rustrak checks at startup that it knows every migration the database has applied. A release that added one refuses to be replaced by an older one, with an error like this: ``` Migration error: migration 20260916000000 was previously applied but is missing in the resolved migrations ``` This protects your data from a server that does not understand its schema. To go back, restore the backup you took before upgrading and start the older image. Each guide says whether its release adds migrations and, where it is safe, how to undo them by hand. --- # Upgrade to 0.15 In 0.15 **the server serves the dashboard**. The page and the API come from one container on one address, and the separate `rustrak-ui` container is no longer needed. Your data, DSNs and API tokens carry over untouched. ## At a glance | | | |---|---| | **Upgrade from** | Any 0.14.x | | **Downtime** | One server restart | | **Database** | One migration: adds the nullable column `installation.telemetry_id`. It runs in milliseconds | | **Going back** | Needs the backup you take before upgrading, or one manual SQL step. See [Going back](#going-back) | | **Needs a decision** | Whether to keep the `ui` container, and whether to keep anonymous telemetry on | ## Before you upgrade **Take a backup.** Once 0.15 has started on your database, a 0.14 server refuses to start on it until the backup is restored. These are the commands for the setups the installation guide describes. **SQLite** (the default `rustrak-server` image, data in the `rustrak_data` volume). Stop the server first, so the copy is consistent: ```bash docker stop rustrak docker run --rm -v rustrak_data:/data -v "$PWD":/backup alpine \ tar czf /backup/rustrak-data-0.14.tgz -C /data . ``` **PostgreSQL** (the 0.14 Compose file, with a `postgres` service). Back up the database and the source map volume: ```bash docker compose exec -T postgres pg_dump -U rustrak --format=custom rustrak > rustrak-0.14.dump docker run --rm -v _sourcemap_data:/data -v "$PWD":/backup alpine \ tar czf /backup/rustrak-sourcemaps-0.14.tgz -C /data . ``` `` is your Compose project name, usually the name of the directory that holds `docker-compose.yml`. `docker volume ls` lists the exact name. ## What changes ### The server serves the dashboard **What changed.** The dashboard is compiled to static files that the server hands out at `/`. Open the server's own address, `:8080` by default, and sign in there. The browser calls the API on the same origin, so the session cookie is first-party and no CORS applies. **Who is affected.** Everyone who runs the `ui` service from the 0.14 `docker-compose.yml`. **What to do.** Remove the `ui` service and its `RUSTRAK_API_URL`, and make sure the server's port is the one people reach. Behind a reverse proxy, point the name your team opens at the server container. See [Reverse proxy](https://rustrak.dev/configuration/production#reverse-proxy). Leaving the `ui` service in place also works: `rustrak/rustrak-ui` 0.15 is nginx serving the same dashboard and passing the API through to the server. On the same host it only adds a container, so remove it unless you want the dashboard on a different host from your data ([Dashboard on its own host](https://rustrak.dev/configuration/production#dashboard-on-its-own-host)). If you do, set `RUSTRAK_DASHBOARD=off` on the server, and `DASHBOARD_URL` to the `ui` address so the links in alert notifications open the dashboard. ### Source maps are kept in the data volume **What changed.** Uploaded source maps are stored in `/data/sourcemaps`, inside the `/data` volume, so they persist with the rest of the data and nothing needs to be set. In 0.14, a server started with only `-v rustrak_data:/data` kept them in a separate unnamed volume that a new container did not reuse. **What to do.** Nothing, if you mount a volume at `/data/sourcemaps` or set [`SOURCEMAP_STORAGE_PATH`](https://rustrak.dev/usage/source-maps#storage) to a mounted path, as the 0.14 Compose file does. If you ran the server with only `-v rustrak_data:/data`, source maps uploaded before the upgrade are not carried over. Upload them again for the releases you still need symbolicated; your application's next deploy uploads the maps for its new release. ### The dashboard is reachable wherever the server is **What changed.** Any address that reaches the server now also shows the sign-in page. **Who is affected.** Installations that expose the server publicly only for SDKs to send events, and keep the dashboard somewhere private. **What to do.** Set `RUSTRAK_DASHBOARD=off` on that server. It then serves the API and nothing else, as in 0.14. ### The sign-in page moves to `/login` **What changed.** It was `/auth/login`. `/auth` belongs to the API, and now that the page and the API share an address, it cannot also be a page. `GET /auth/login` answers a JSON 404. Every other page keeps its address, so links to projects and issues keep working. **What to do.** Update bookmarks, password manager entries and documentation that point at `/auth/login`. Opening any page while signed out also takes you to the sign-in page. ### Anonymous telemetry is on by default **What changed.** The server sends one anonymous report every six hours: version, platform, database backend, memory use, rounded counts, and health counters. Never IPs, hostnames, URLs, names, DSNs, event payloads or messages. The first report leaves ten minutes after startup, and the startup log says whether telemetry is on. [Telemetry](https://rustrak.dev/configuration/telemetry) lists every field. **What to do.** Nothing, if you are fine with it. To turn it off, set `RUSTRAK_TELEMETRY=off` or `DO_NOT_TRACK=1` before starting 0.15. To see exactly what would be sent, call `GET /api/telemetry/preview` as an admin. ### Links in alert notifications follow `PUBLIC_URL` **What changed.** The issue links in Slack, email and webhook alerts are built from `DASHBOARD_URL` if it is set, and from `PUBLIC_URL` if it is not. In 0.14 the fallback was `http://localhost:3000`. **What to do.** If you set `DASHBOARD_URL` to the address of your old `ui` container, change it to the server's public address or remove it. Keep it only if the dashboard is served from a different host than `PUBLIC_URL`. ### The update check runs in the browser **What changed.** The notice about a new Rustrak version is now checked by the dashboard in your browser instead of by the `ui` container, and it always runs. `RUSTRAK_VERSION_CHECK_ENABLED` no longer exists. **What to do.** Remove `RUSTRAK_VERSION_CHECK_ENABLED` from your configuration if you set it. ## What stays the same - **Data.** Projects, issues, events, releases, alerts and users are untouched. - **SDKs.** DSNs do not change, and SDKs keep sending to the same address. - **API tokens and the API.** Every endpoint is where it was. `@rustrak/client` and `@rustrak/mcp` 0.15 have no breaking changes. - **Sessions.** People signed in on the server's address stay signed in. People who used a separate dashboard address sign in once on the new one. ## New settings All optional. | Variable | Default | What it does | |---|---|---| | [`RUSTRAK_DASHBOARD`](https://rustrak.dev/configuration/environment#rustrak_dashboard) | `on` | `off` keeps the server API-only | | [`RUSTRAK_DASHBOARD_DIR`](https://rustrak.dev/configuration/environment#rustrak_dashboard_dir) | `./static` | Where the compiled dashboard is | | [`RUSTRAK_TELEMETRY`](https://rustrak.dev/configuration/telemetry) | `on` | `off` sends nothing | | [`DO_NOT_TRACK`](https://rustrak.dev/configuration/telemetry) | unset | `1` sends nothing | | [`SOURCEMAP_CACHE_MB`](https://rustrak.dev/configuration/environment#sourcemap_cache_mb) | `64` | Memory kept for parsed source maps | ## Upgrade ### Docker Compose Change the image tags, remove the `ui` service, and start again. If your file still uses the `latest` and `postgres` tags from the 0.14 installation guide, `docker compose pull` alone moves the server to 0.15 once it is released; pinning a version as below makes the upgrade a change you choose. ```diff services: server: - image: rustrak/rustrak-server:v0.14.14-postgres + image: rustrak/rustrak-server:v0.15.0-postgres ports: - "8080:8080" environment: - PUBLIC_URL=https://rustrak.example.com + # Optional: anonymous telemetry is on unless you say otherwise. + # - RUSTRAK_TELEMETRY=off - - ui: - image: rustrak/rustrak-ui:v0.14.14 - ports: - - "3000:3000" - environment: - - RUSTRAK_API_URL=http://server:8080 ``` ```bash docker compose pull docker compose up -d --remove-orphans ``` `--remove-orphans` stops the old `ui` container now that the file no longer lists it. ### Docker, SQLite ```bash docker stop rustrak && docker rm rustrak docker run -d \ --name rustrak \ -p 8080:8080 \ -v rustrak_data:/data \ -e SESSION_SECRET_KEY= \ -e PUBLIC_URL=https://rustrak.example.com \ rustrak/rustrak-server:v0.15.0 ``` Keep the same `SESSION_SECRET_KEY`, or everyone is signed out. ## Check it worked 1. The server log shows, in this order: ``` Database migrations completed successfully Serving the dashboard from /app/static Anonymous telemetry is on (...) # or: Telemetry is off: ... ``` 2. `https://rustrak.example.com/login` shows the sign-in page, and you can sign in with your usual account. 3. Settings, About shows version 0.15.0. 4. New errors from your applications appear in their projects. ## Going back Stop the 0.15 server and restore the backup you took before upgrading, then start your previous image. **SQLite:** ```bash docker stop rustrak && docker rm rustrak docker run --rm -v rustrak_data:/data -v "$PWD":/backup alpine \ sh -c 'rm -rf /data/* && tar xzf /backup/rustrak-data-0.14.tgz -C /data' ``` **PostgreSQL:** ```bash docker compose stop server docker compose exec -T postgres pg_restore -U rustrak -d rustrak --clean --if-exists < rustrak-0.14.dump docker run --rm -v _sourcemap_data:/data -v "$PWD":/backup alpine \ sh -c 'rm -rf /data/* && tar xzf /backup/rustrak-sourcemaps-0.14.tgz -C /data' ``` Restoring discards everything received after the backup. If you would rather keep that data, undo the one migration by hand instead: stop the server, run the SQL below against the database, and start your previous image. ```sql ALTER TABLE installation DROP COLUMN telemetry_id; DELETE FROM _sqlx_migrations WHERE version = 20260916000000; ``` **SQLite:** ```bash docker run --rm -v rustrak_data:/data alpine sh -c 'apk add -q sqlite && sqlite3 /data/rustrak.db \ "ALTER TABLE installation DROP COLUMN telemetry_id; DELETE FROM _sqlx_migrations WHERE version = 20260916000000;"' ``` **PostgreSQL:** ```bash docker compose exec -T postgres psql -U rustrak -c \ "ALTER TABLE installation DROP COLUMN telemetry_id; DELETE FROM _sqlx_migrations WHERE version = 20260916000000;" ``` The commands above use `rustrak` as the PostgreSQL user and database, as the installation guide does. Use your `POSTGRES_USER` and `POSTGRES_DB` if they differ. --- # Upgrade to 0.25 In 0.25 access comes from **roles you define**, made of granular permissions, and API tokens can be limited to permissions, projects and a date. Everyone keeps the project access they had. Three things stop working unless you act: members creating projects, members with no project creating tokens, and scripts that create tokens or set project members. ## At a glance | | | |---|---| | **Upgrade from** | Any 0.24.x | | **Downtime** | One server restart | | **Database** | Three migrations. The main one turns `project_members` into `roles`, `role_permissions` and `role_assignments`; the others add columns to `invitations`. At the first start the server hashes every stored API token | | **Going back** | Needs the backup you take before upgrading. See [Going back](#going-back) | | **Needs a decision** | Who may create projects from now on | ## Before you upgrade **Take a backup**, as in [Upgrade to 0.15](https://rustrak.dev/upgrading/0-15#before-you-upgrade). Tokens cannot be unhashed, so the backup is the only way back with them intact. **Copy any token you only keep in the dashboard.** After the upgrade Rustrak cannot show a token's value again. Tokens already in your CI keep working. ## What changes ### Project roles become editable roles Each project level in use becomes an ordinary role, under **Settings → Roles**. Rename, edit or delete them like any other. | Before | After | |---|---| | Project **Viewer** | Role **Viewer**, view on every project resource | | Project **Editor** | Role **Editor**: Viewer, plus changing issues, releases, source maps, alert rules, monitors, project settings and the repository link | | Project **Admin** | Role **Project Admin**: Editor, plus managing members and deleting the project | Only levels someone holds are created. Each person keeps the same level on the same projects. All three roles also get `api-tokens:create`, so their holders can still create tokens. Instance admins are now shown as **Owner** and keep full access. The API still says `admin`, and so does `OIDC_ADMIN_GROUPS`. Instance members stay members. A new install ships no roles at all. See [Roles & Permissions](https://rustrak.dev/usage/roles). ### Who may create projects **What changed.** Any signed-in user could create a project and became its admin. Now it takes `projects:create`, which no migrated role includes, plus at least one project permission. The creator's own roles extend to the new project; nobody becomes its admin. **What to do.** If members create projects, add **Projects** with **Create** to a role under the **Workspace** tab, or give it as [custom permissions](https://rustrak.dev/usage/team#custom-permissions). Owners need nothing. ### Who may create API tokens **What changed.** Creating a token takes `api-tokens:create`. A member with no project role has no permission to hold, so they can no longer create one. Their existing tokens keep working. **What to do.** Give those members a role that includes it, if they need new tokens. ### API tokens **What changed.** - Tokens are stored as a SHA-256 hash. `GET /api/tokens/{id}` and the token list show the first 8 characters, as `token_prefix`. Only the response that creates a token shows its value. - Existing tokens keep full access, limited to what their owner holds. - A token can no longer create tokens. `POST /api/tokens` needs a signed-in session and answers `403` otherwise. - The MCP tool `create_token` is gone. - `POST /api/tokens` refuses permissions its caller does not hold. **What to do.** If a script creates tokens with a token, create them once from the dashboard instead and store them. To limit an existing token, replace it: limits are set at creation. ### Other API changes **Who is affected.** Scripts and tools that call these endpoints, and anything built on [`@rustrak/client`](https://rustrak.dev/sdks/client). | Endpoint | Before | Now | |---|---|---| | `PUT /api/projects/{id}/members` | `{"user_id": 2, "role": "editor"}` | `{"user_id": 2, "role_ids": [2]}` | | `GET /api/projects/{id}/members` | One `role` per member | A list of roles per member | | `DELETE /api/invitations/{id}` | Took the invitation token | Takes the invitation `id` | | `GET /api/projects`, `GET /api/projects/{id}` | Always returned `sentry_key` and `dsn` | Empty without `client-keys:read` | Team, invitation, storage, telemetry, GitHub and MCP settings endpoints used to require an instance admin. Each now checks its own permission, so a member with the right role passes. New endpoints: `/api/roles`, `/api/users/{id}/assignments`, `/api/access/catalog` and `/api/access/me`. ## After you upgrade 1. Open **Settings → Roles**: you should see the roles above, with their assignment counts. 2. Open a member under **Settings → Members**: **Access** lists their roles and projects. 3. Run a job that uses a token, such as a source map upload. The server log says how many tokens it hashed: `Hashed N API token(s) stored before tokens were hashed`. ## Going back Stop the server, restore the backup you took before upgrading, and start your previous image, as in [Upgrade to 0.15](https://rustrak.dev/upgrading/0-15#going-back). Whatever changed since the upgrade is lost, including roles and new tokens. --- # API Reference Every endpoint, with its parameters, request and response schemas and the authentication it takes, is in the [interactive API reference](https://rustrak.dev/api-reference). It is generated from the server's OpenAPI spec, and you can send requests from it with your token. ## Authentication Management endpoints take an [API token](https://rustrak.dev/usage/tokens) as a bearer token. Set `RUSTRAK_URL` to your server's address and `RUSTRAK_TOKEN` to the token: ```bash curl -H "Authorization: Bearer $RUSTRAK_TOKEN" "$RUSTRAK_URL/api/projects" ``` Create one under **Settings → API Tokens**. SDKs authenticate ingestion with the key in their DSN instead. For TypeScript, [`@rustrak/client`](https://rustrak.dev/sdks/client) wraps every endpoint with typed methods. ## The spec from your own server The published images are built without the `openapi` feature, so they do not serve the spec: use the reference above. A server you build with it serves two more public endpoints: | Endpoint | What it is | |---|---| | `GET /api-docs/openapi.json` | The OpenAPI 3 spec | | `GET /docs` | The same interactive reference, served by the server | ```bash cargo build --release --features openapi ``` To write the spec to `apps/server/openapi.json` without running the server: ```bash cargo run --bin gen_openapi --features openapi ``` --- # Architecture How Rustrak is built, and why. ## Overview ``` ┌─────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐ │ Sentry SDK │────▶│ Rustrak server │────▶│ SQLite (default) │ │ (your app) │ │ (Rust / Actix-web) │ │ or PostgreSQL │ └─────────────────┘ │ │ └──────────────────┘ │ /api ingest and API │ ┌─────────────────┐ │ / dashboard │ │ Browser │────▶│ (static files) │ └─────────────────┘ └──────────────────────────┘ ``` One process in one container answers everything: SDKs send events to `/api`, and people open the dashboard at `/` on the same port. The image is about 20 MB and the server idles under 100 MB of memory. ## Key design decisions ### One process, one origin The dashboard is a React application (Vite and TanStack Router) compiled to static files and built into the server image. The same Actix process that answers `/api/projects` hands out `/`, so: - There is one container, one port and one address to configure (`PUBLIC_URL`) - The browser calls the API on the origin it loaded the page from, so the session cookie is first-party and the dashboard needs no CORS - The dashboard costs nothing at runtime: the server reads the files once at startup - Paths under `/api`, `/auth`, `/health`, `/docs`, `/api-docs`, `/metrics`, `/mcp` and `/.well-known` always get an API answer, never the dashboard page The dashboard stays optional. `RUSTRAK_DASHBOARD=off` makes the server API-only, and a server built without a dashboard (a plain `cargo build`) serves the API and nothing else. ### Two-phase ingestion Events go through two phases: ``` SDK → Server → Temp Storage → [Background] → Database │ └→ Return 200 OK (<50ms) ``` 1. **Ingest** (sync): Receive, validate, store temporarily, return 200 2. **Digest** (async): Calculate grouping, create/update issue, store event Your apps get a fast response while the heavy processing happens in the background. An event accepted by ingest is kept on disk until its digest has committed, so a restart does not lose it. ### Sentry protocol compatibility Rustrak implements the Sentry envelope protocol: - Use any official Sentry SDK - No custom SDK to maintain - Point your DSN at Rustrak ## Components ### Server (Rust) Responsibilities: - Receive events, transactions, spans, sessions, logs, check-ins and user feedback from SDKs - Group errors into issues - Apply source maps and debug files to stack traces - Store data in SQLite or PostgreSQL - Provide the REST API - Serve the dashboard - Send alerts - Enforce rate limits Tech: Rust, Actix-web, SQLx, Tokio ### Database SQLite by default, stored at `/data/rustrak.db`, or PostgreSQL with the `postgres` image. The main tables: - `projects`: project configuration - `issues` and `groupings`: grouped errors and their grouping keys - `events`: individual error events - `transactions`, `spans`, `logs`, `releases`, `session_counts`: performance, logs and release health - `users`, `auth_tokens`, `roles`, `role_permissions`, `role_assignments`: accounts and access - `alert_rules`, `alert_integrations`, `alert_history`: alerting Uploaded source maps are files on disk under `SOURCEMAP_STORAGE_PATH` (default `/data/sourcemaps`), inside the same `/data` volume as SQLite. ### Dashboard A React single-page application in `apps/dashboard`, compiled into the server image: - Projects, issues and events (stack traces, breadcrumbs, context) - User feedback, releases, performance, logs, crons and AI agent traces - Alerts, team, tokens and storage settings ## Authentication | Method | Use case | How | |--------|----------|-----| | Session | Dashboard | Sign in at `/login` → httpOnly cookie | | Bearer Token | API access | `Authorization: Bearer ` | | SentryAuth | SDK ingestion | DSN `sentry_key` | | OAuth | AI assistants on `/mcp` | An access token from Rustrak's own authorization server | ## Deployment options ### Default: one container ``` SDKs and browsers → Server (API + dashboard) → SQLite or PostgreSQL ``` The setup the [installation guide](https://rustrak.dev/getting-started/installation) describes. Put a reverse proxy in front for HTTPS; it has one upstream. ### API only ``` SDKs and scripts → Server (API) → SQLite or PostgreSQL ``` Set `RUSTRAK_DASHBOARD=off`. The server serves no pages. ### Dashboard on its own host ``` SDKs → Server (API, RUSTRAK_DASHBOARD=off) → Database ▲ │ /api, /auth, /health, /docs, /api-docs │ Browser → rustrak-ui (nginx + dashboard) on another host ``` For keeping the machine that holds your data free of any frontend. `rustrak/rustrak-ui` serves the same dashboard and proxies the API paths to `RUSTRAK_API_URL`, so the browser still sees one origin. Set `DASHBOARD_URL` on the server to the `ui` address so alert links open it. See [Dashboard on its own host](https://rustrak.dev/configuration/production#dashboard-on-its-own-host). Whichever you pick, run one server process per database. --- # Contributing How to set up Rustrak locally and get a change merged. ## Setup ### Prerequisites - Rust through `rustup`. The version is pinned in `rust-toolchain.toml` (1.98), and `rustup` installs it on the first `cargo` command - Node.js 22.18 or later - pnpm 12 - Docker, optional: only for PostgreSQL or to build images ### Clone and install ```bash git clone https://github.com/rustrak/rustrak.git cd rustrak pnpm install ``` ### Start development ```bash # Run the server on :8080, with SQLite. The .env keeps the database and # source maps inside the checkout instead of /data. cd apps/server && cp .env.example .env && cargo run # Run the dashboard on :3000, proxying the API to :8080 (separate terminal) cd apps/dashboard && pnpm dev ``` The server uses SQLite unless told otherwise. To work against PostgreSQL: ```bash docker compose -f docker-compose.dev.yml up -d postgres cd apps/server && DATABASE_URL=postgres://rustrak:rustrak@localhost:5432/rustrak \ cargo run --no-default-features --features postgres ``` To have the server itself serve the dashboard, as it does in production, run `pnpm build --filter=@rustrak/server` from the root: it builds the dashboard, copies it to `apps/server/static` with `scripts/bundle-dashboard.sh`, and builds the server. ## Project structure ``` rustrak/ ├── apps/ │ ├── server/ # Rust server: API, ingest, MCP, and the dashboard from static/ │ ├── dashboard/ # React SPA, compiled into apps/server/static │ └── docs/ # Documentation site ├── packages/ │ ├── ui/ # Design system │ ├── client/ # TypeScript API client │ ├── test-sentry/ # CLI that sends test events │ └── benchmarks/ # Load and throughput benchmarks ├── scripts/ │ └── bundle-dashboard.sh # copies the dashboard build into apps/server/static └── docker-compose.yml ``` ## Workflow ### Branches - `main`: stable releases - `next`: pre-releases - `feat/*`, `fix/*`, `docs/*`: your work, branched from `main` ### Commits Use conventional commits, in English: ``` feat: archive issues from the list fix: count rate-limited events once docs: document user feedback ``` A change users will notice comes with a changeset (`pnpm changeset`). Name `@rustrak/server`; the client and dashboard follow, since all three share one version. ### Pull requests 1. Branch from `main` 2. Make the change, with its tests 3. Run `pnpm run ci`, which runs what CI runs 4. Open the pull request ## Server (Rust) ### Tests ```bash cd apps/server cargo test ``` ### Style ```bash cargo fmt cargo clippy ``` ### Migrations Each backend has its own set, in `migrations/sqlite` and `migrations/postgres`. Add a migration to both: ```bash sqlx migrate add -r --source migrations/sqlite description sqlx migrate add -r --source migrations/postgres description ``` The server runs pending migrations at startup. ## Dashboard React 19, TanStack Router and Vite. It compiles to static files that the Rust server hands out, so there is no Node process in production: `pnpm dev` runs Vite on `:3000` and proxies the API prefixes to the server, which is the same one-origin arrangement production has. ### Tests ```bash cd apps/dashboard pnpm test pnpm lint ``` ### Strings and translations Every user-facing sentence lives in `src/shared/i18n/messages/`, one JSON dictionary per locale, and nowhere else. A new string goes into **all five** dictionaries, and is read through a translator rather than written inline. Dates, numbers and percentages are never formatted by hand. `toLocaleString()` and a bare `Intl` formatter use the browser's locale rather than the one the reader chose in Rustrak. Use `use-intl`'s `useFormatter` with one of the named formats in `src/shared/i18n/config.ts`. The architecture suites fail the build on all of this. ### Adding a language The server needs no change: it accepts any well-formed language tag, so a locale is added entirely in the dashboard. 1. Copy `src/shared/i18n/messages/en.json` to your locale's tag, for example `fr.json`, and translate the values. Keep every key: a missing one renders as the raw key, not as English 2. Add the tag to `LOCALES` in `src/shared/i18n/routing.ts` 3. Add the dictionary to `OTHER_LOCALES` in `src/__tests__/architecture/message-keys.test.ts`, which holds every locale to the same keys as English 4. Run `pnpm test` ## Documentation ### Run locally ```bash pnpm dev:docs # http://localhost:3001 ``` ### Add pages 1. Create `.mdx` in `content/` 2. Add to `_meta.js` 3. Link from related pages ## Getting help Report a bug or ask a question in [GitHub issues](https://github.com/rustrak/rustrak/issues). --- # Docs for AI assistants An assistant may not know Rustrak yet, or know an older version. Give it one of these files and it answers from the current docs. | File | What it holds | Give it when | |---|---|---| | [`/llms.txt`](https://rustrak.dev/llms.txt) | An index of every docs page, one line each, with a link to its Markdown | The assistant can fetch pages on its own | | [`/llms-full.txt`](https://rustrak.dev/llms-full.txt) | Every page in one file, about 230 KB | You paste the docs in, or the assistant cannot browse | | [`/openapi.json`](https://rustrak.dev/openapi.json) | Every API endpoint, with its parameters and schemas | It writes code against the Rustrak API | Every docs page also has a Markdown version at the same address with `.md` added, such as [`/usage/issues.md`](https://rustrak.dev/usage/issues.md). Each docs page links to it from its ``, and every page links to `/llms.txt`, so an agent reading the site finds them on its own. ## Connect an assistant to your server The files above teach an assistant about Rustrak. To let it read your own issues, events and logs, turn on the [MCP server](https://rustrak.dev/sdks/mcp) built into Rustrak and connect the assistant to it. --- # Half the memory, twice the throughput: a pass over ingest and digest Rustrak's pitch is that it runs on the machine you already have. That only holds if a bad afternoon in production, the kind where an app throws twenty thousand errors in a minute, does not turn the tracker into the next thing that falls over. So we sat down with `perf` and `strace`, sent the server a 20k-event burst, and looked at what it was actually doing. ## What the profile said Application CPU was not the problem. The grouping regex was about 1% of the profile, SQLite about 10%, the allocator about 3%. The time went to thread hand-offs and to a few structural decisions that were fine at low volume and wrong under a burst. **Memory grew with the burst, not with the cap.** Ingestion is two-phase: the endpoint writes the envelope to disk, answers `200`, and spawns a task that does the database work. At most 16 of those tasks run at once; the rest wait for a permit. The catch was that every waiting task carried its entire async state machine inline, about 5.5 KB of it, before it had done anything. Sixteen thousand queued tasks came to roughly 195 MB of resident memory. `MALLOC_ARENA_MAX` changed nothing, which ruled out allocator fragmentation. **The recovery worker replayed the live queue.** On startup, and periodically after that, the server scans the ingest directory for files a previous process left behind and digests them. Under a backlog that scan saw every file the running tasks had not reached yet, read them all, and digested them on top of the tasks that already owned them. In one 20k run that was 16 000 failed reads, 15 duplicate digests and three unique-constraint violations, plus a full read of the whole queue on every pass. **Nine round trips to the blocking pool per accepted event.** Storing an event went through `tokio::fs` one call at a time: open, write, flush, fsync, link, unlink, open the directory, fsync it, close. Each one is a hop to the blocking thread pool and back, and each hop is a futex wake and a context switch. They were the top of the profile. **SQLite's busy handler as a queue.** Sixteen digests contended for the write lock through SQLite's busy handler, which polls with sleeps that grow to 100 ms. That is about five `clock_nanosleep` calls per event, and the lock sat idle between one commit and the next poll. On top of that, every digest ran its own `FULL` checkpoint before deleting its file: two fsyncs and a page copy per event. ## What changed - The digest future is boxed only after it holds a permit. A waiting task used to carry the digest's whole state machine, about 5.5 KB, before doing anything; now it holds a few hundred bytes, and the working set (payload, parsed JSON, grouping) is bounded by the concurrency cap rather than the burst size. - An in-flight registry. The ingest route claims `(project_id, event_id)` before the file exists. The recovery scan skips owned files by name, before reading them, and treats a file that vanished between listing and replay as handled rather than failed. - The store is one blocking-pool round trip. Write, publish and directory sync run as a single `spawn_blocking` with `std::fs`. Same logic, same fsyncs, one hop. - Fewer statements per digest. The project and installation rows are read once. The grouping and its issue come back in one `JOIN`. The event insert no longer does `RETURNING *`, which was re-parsing the whole payload into a second JSON tree just to drop it. The platform-inference `UPDATE` is skipped once a project has a platform. - On SQLite, digests take turns at the write lock in process on a `tokio::sync::Mutex` instead of through the busy handler, and durability checkpoints are batched: committed digests enqueue their file and return, and a background worker runs one `FULL` checkpoint per 25 ms window for everything that committed in it. - Rate-limit window counts compare `digested_at` as text in the column's own format, so the `(project_id, digested_at)` index serves the scan instead of a full table scan. The durability contract is unchanged. A pending file is still deleted only after a completed `FULL` checkpoint that started after its commit, and a `kill -9` twelve seconds into a burst followed by a restart on the same database replayed every acknowledged event exactly once. Nothing about the wire protocol moved either: the same envelopes are accepted, the same responses come back. ## Numbers Fresh instance, SQLite, 20 000 error events of about 4.8 KB each at 32 connections. The load generator ran on the same 4-core box, so the absolute figures are conservative; the deltas are the point. | | Before | After | |---|---|---| | Ingest throughput | ~2.3k req/s | ~5k req/s | | Ingest p50 / p99 | 13 ms / 26 ms | 6 ms / 12 ms | | Digest throughput | ~510 events/s | ~1000 events/s | | Server CPU for the run | 56 s | 31 s | | Peak memory | 195 MB | 53 MB | | Recovery-worker failed reads / duplicate digests | 16k / 15 | 0 / 0 | A 50 000-event run at 64 connections finished with an empty ingest directory, no warnings and 76 MB peak. Idle memory is unchanged at about 25 MB. PostgreSQL gets everything that is not SQLite-specific: the far smaller footprint per queued event, no replay of the live queue, one blocking round trip per stored event, and five fewer statements per digest. We also tried `mimalloc` and did not adopt it. The allocator is 3% of the profile and the queued-task fix took most of the burst memory away, so it did not justify a C build dependency in the distroless image. ## What this means for a deployment The sizing advice in the [production guide](https://rustrak.dev/configuration/production#performance) still stands, with more headroom in it than before. A server that used to need a couple of hundred megabytes to absorb a burst now stays under 60 MB, and gets through the burst in half the time. --- # Refactoring Rustrak's Event Ingestion into a Processor Pipeline This refactor started with a small bug: transactions were showing up in the dashboard with the title **"Unknown"**. ## The one-line fix that wasn't enough Rustrak ingests data using the Sentry envelope format, a stream of typed items: `event`, `transaction`, `session`, and so on. For a long time only error events mattered, so the ingestion handler took a shortcut: ```rust if matches!(t, "event" | "transaction" | "security") && event_item.is_none() { event_item = Some(item); } ``` Once we began handling performance data, that `matches!` pulled transactions into the error pipeline, where they were fingerprinted as if they were exceptions. A transaction has no exception to group on, so it fell through to an `"Unknown"` title. The fix was one line: ```rust if t == "event" && event_item.is_none() { ... } ``` It stopped the mislabeling, but it also silently dropped every transaction, so no performance data was stored at all. We had replaced a visible bug with an invisible one. That usually means the problem is the design, not the line of code. ## What the old pipeline looked like The whole ingestion path went through a single async function, `digest::worker::process_event()`, about 337 lines of it, and dispatch was a string `match` spread across the handler. Sessions were handled inline. Transactions had nowhere to go. Anything new had to be added to the one function that already did everything. ```mermaid flowchart LR SDK([Sentry SDK]) --> ING[Ingest: auth, decompress] ING --> PARSE[EnvelopeParser → untyped items] PARSE --> MATCH{string match on type} MATCH -->|event| WORK[worker.rs: one 337-line digest fn] MATCH -->|transaction| DROP[mislabeled, then dropped] MATCH -->|session| INLINE[handled inline] WORK --> DB[(PostgreSQL)] classDef bad fill:#ffe3e3,stroke:#e03131,color:#b02525; class MATCH,WORK,DROP bad; ``` There were three issues here: 1. **String dispatch is invisible to the compiler.** A typo or a missing case is a runtime problem, not a build error. The `"Unknown"` regression was one of these. 2. **One function owns every concern.** Source-map rewriting, grouping, issue creation, and alerts all lived in the same body. Adding transactions meant growing it further or branching around it. 3. **Item types aren't first-class.** An item was a slice of bytes plus a string. Nothing in the type system distinguished a transaction from an error. ## The fix: separate the data from the behavior We rebuilt the core around an idea taken from Sentry's own ingestion service, [Relay](https://github.com/getsentry/relay): separate what an item *is* from what we *do* with it. The parser now assigns a real Rust type to every item, once, at parse time: ```rust pub enum EnvelopeItemKind { Event(Vec), Transaction(Vec), Session(SessionUpdate), Sessions(SessionAggregates), Other(String, Vec), // forward-compatible catch-all } ``` Dispatch becomes a single, exhaustive `match`. There is exactly one place in the codebase where item types are routed, and the build fails if a variant has no destination: ```rust pub fn route(item: &EnvelopeItemKind) -> Route { match item { EnvelopeItemKind::Event(_) => Route::Error, EnvelopeItemKind::Transaction(_) => Route::Transaction, EnvelopeItemKind::Session(_) | EnvelopeItemKind::Sessions(_) => Route::Session, EnvelopeItemKind::Other(_, _) => Route::Ignored, } } ``` Each route lands on its own **processor**, an independent unit that implements one small trait: ```rust pub trait Processor { type Input; fn process(&self, work: Self::Input, ctx: &ProcessorCtx) -> impl Future> + Send; } ``` `ErrorProcessor`, `TransactionProcessor`, and `SessionProcessor` each own their dependencies and their logic. The old 337-line function wasn't thrown away; it became the body of `ErrorProcessor`, while a registry built once at startup wires everything together. ```mermaid flowchart LR SDK([Sentry SDK]) --> ING[Ingest: auth, decompress] ING --> PARSE[EnvelopeParser → typed enum] PARSE --> ROUTE{route — exhaustive match} ROUTE -->|Event| ERR[ErrorProcessor] ROUTE -->|Transaction| TXN[TransactionProcessor] ROUTE -->|Session| SES[SessionProcessor] ROUTE -->|Other| IGN[ignored, forward-compat] ERR --> DB[(PostgreSQL / SQLite)] TXN --> DB SES --> DB classDef good fill:#e7f9d5,stroke:#84c800,color:#3d6b00; class ROUTE,ERR,TXN,SES good; ``` ## Why a trait, and not `dyn` or an `if` ladder A few deliberate choices: - **Static dispatch, no `dyn`.** Dispatch happens through generics, so the compiler monomorphizes and inlines each processor call. There's no vtable, no boxed future, and no per-event heap allocation on the hot path. This follows how Relay structures its processors. - **Native `async fn` in traits.** We write the return type as `impl Future + Send` rather than using the `async-trait` crate. That keeps the futures allocation-free and `Send`, which is what `tokio::spawn` needs on the ingestion path. - **The enum is pure data.** `EnvelopeItemKind` lives in the ingest layer and knows nothing about processors or databases. Routing is behavior, and it lives one layer up, so the enum stays testable without a database. ## How this helps us scale The main benefit is what it costs to add the next event type, such as spans, check-ins, or profiles. The change surface is now small and fixed: | Step | Change | | --- | --- | | Add the variant | `EnvelopeItemKind::Span(Vec)` | | Add the route | one `match` arm in `route()` | | Add the processor | a new `digest/processors/span.rs` | | Register it | one field in the registry | The compiler enforces the rest: if the `match` arm is missing, the build fails at that line. A string typo can no longer route a span into the error pipeline, and a new type can't be silently dropped. The class of bug behind the `"Unknown"` labels is now prevented by the type system rather than patched after the fact. We shipped this backend-first, behind a clean processor boundary. Errors still flow through the durable two-phase path we [described in an earlier post](https://rustrak.dev/blog/introducing-rustrak), transactions are stored directly, and sessions are aggregated, all through the same trait. The change ended up smaller than the code it replaced (a 337-line function removed and its logic redistributed), is fully tested, and was verified end-to-end with real SDK traffic. A small mislabeling bug turned out to be a good reason to make the ingestion core easier to extend. The one-line fix would have hidden the problem; the refactor removed it. --- # Source maps in Rustrak Source map support had been sitting on the roadmap for a while. Not urgent — the server was working, errors were coming in, alerts were firing. Good enough for now. Until we started looking at real stack traces in production: ```bash Error: {"type":"Unknown","message":"Unknown Error"} 04 ? app:///_next/server/chunks/ssr/_0bwf~zv._.js:2:239718 03 ? app:///_next/server/chunks/ssr/apps_myapp_src_lib_actions_invoices_ts_09hq4k7._.js:2:5956 02 process.processTicksAndRejections node:internal/process/task_queues:103:5 01 Module.k [as generateMetadata] app:///_next/server/chunks/ssr/[root-of-the-server]__0fitma~._.js:2:5672 ``` Four frames, all useless. Function names are `?`, files are Turbopack chunk hashes, column number is 239718, everything on line 2 because the bundler flattens the code. No real clue where the problem is. Source maps moved to this week's list. ## The protocol you have to read from source code My first assumption was that source maps would arrive through the same ingestion endpoint as events. They don't. Sentry uses a separate three-step chunked upload protocol implemented in [sentry-cli](https://github.com/getsentry/sentry-cli). The official docs describe it conceptually but skip exactly the details you need to reimplement it. I ended up reading the source code directly to understand what the client was actually doing. **Step 1: the client asks what the server accepts.** Before uploading anything, sentry-cli calls [`get_chunk_upload_options`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/mod.rs#L654), which sends a `GET /organizations/{org}/chunk-upload/`. The server responds with a [`ChunkServerOptions`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/data_types/chunking/upload/options.rs#L10) object that includes the actual upload URL (not a fixed path), max chunk size, max chunks per request, and whether gzip is supported. **Step 2: upload the chunks.** The client calls [`upload_chunks`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/mod.rs#L372), which POSTs a multipart form to the URL from step one. Each part has field name `"file"` (or `"file_gzip"` for compressed) and the filename is the chunk's SHA-1 hash in hex. Pure CAS: uploading the same chunk twice is a no-op. **Step 3: assemble.** The client calls [`assemble_artifact_bundle`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/mod.rs#L679) with the body defined in [`ChunkedArtifactRequest`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/data_types/chunking/artifact.rs#L7): the bundle checksum, the list of chunk hashes, and the list of projects. The server responds with [`AssembleArtifactsResponse`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/data_types/chunking/artifact.rs#L18). What took me a while to understand is that the assembly response has five possible states, not two. The states are defined in [`ChunkedFileState`](https://github.com/getsentry/sentry-cli/blob/1d5191995ab4e8c8c058055f912f38e7dfa1039c/src/api/data_types/chunking/file_state.rs#L3): `not_found` (chunks are missing), `created` and `assembling` (in progress), `ok` (done), and `error`. If the state is `not_found`, the response includes `missingChunks` with the hashes to re-upload. If it's `assembling` or `created`, the client polls until it changes to `ok` or `error`. The assembled bundle is a ZIP. Inside are the source map files plus a `manifest.json` mapping each file to its debug ID. ## Storage The storage part was the most straightforward. CAS over a filesystem is exactly what you'd expect: ``` {SOURCEMAPS_DIR}/{key[0..2]}/{key[2..]}.map ``` The SHA-1 hash `abc123def456...` gets stored at `ab/c123def456....map`. The two-character prefix keeps the number of files per directory bounded, same scheme git uses for its objects. Writes go to a tempfile first, then rename. Rename on POSIX is atomic, so a reader never sees a half-written file. If the file already exists, the write is skipped — the hash guarantees identical content, so two concurrent uploads of the same chunk can't overwrite each other. One thing worth not forgetting: the key comes from the client, so it needs to be validated before being used as a path component. If you don't reject keys containing `/`, `\`, or `..`, you're opening a path traversal. Easy to miss, hard to catch later. ## Rewriting frames When an event arrives with a minified stack trace, the SDK attaches a `debug_meta` block with a list of images: ```json { "debug_meta": { "images": [ { "type": "sourcemap", "code_file": "https://example.com/static/main.js", "debug_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } ] } } ``` The debug ID links a specific build of a file to its source map. Frame rewriting builds a `code_file -> debug_id` map from those entries, and for each stack frame looks up the source map, does the position lookup, and replaces the filename and line number with the originals. If the source map has `sourcesContent`, you also get the original source context lines. All of this happens in the digest phase, not ingestion. Ingestion stays synchronous and fast. ## The coordinate bug This one kept me busy for a bit. Sentry uses 1-indexed line numbers. Source maps use 0-indexed. That part isn't surprising. The issue is what `lineno: 0` means in Sentry's event format: it doesn't mean "line 1", it means "unknown position". I couldn't find this documented anywhere — got it from reading the JavaScript SDK source. If you do the subtraction without accounting for that case: - `lineno: 1` becomes `0`, correct - `lineno: 0` becomes `-1`, or underflow on u32, wrong - `lineno: null` and you have nothing to work with The function that handles it is small: ```rust fn normalize_sentry_position(lineno: Option, colno: Option) -> Option<(u32, u32)> { match (lineno, colno) { (Some(line), Some(col)) if line > 0 => Some((line - 1, col)), _ => None, } } ``` Returns `None` when the position is unknown and skips the lookup. The frame stays as-is instead of being silently mapped to the wrong line. The concerning thing about this bug is that generic tests won't catch it. Most events have a valid `lineno`. You have to write a test that specifically sends an event with `lineno: 0` and verify the frame isn't rewritten. Without that, the bug ships and you get wrong mappings in production that are nearly impossible to diagnose. --- # Introducing Rustrak: Self-Hosted Error Tracking for the Paranoid Error tracking is one of those things that feels solved. Sentry exists. Rollbar exists. Datadog will happily charge you $400/month to tell you your server is on fire. So why build another one? Because most teams don't need a SaaS platform. They need a binary that runs in Docker, speaks the Sentry protocol, and doesn't phone home. ## The Problem with the Alternatives Sentry is excellent. Their open-source self-hosted version is also excellent — and it requires PostgreSQL, Redis, Kafka, Zookeeper, Celery, and a minimum of 8GB RAM to run locally. That's fine for a company. It's overkill for a startup, a side project, or a team that just wants to know when their app crashes. The lightweight alternatives either abandoned the Sentry SDK ecosystem (forcing you to rewrite your instrumentation) or they're still cloud-only. ## What Rustrak Does Rustrak is an API server written in Rust that accepts events from any Sentry SDK and stores them in PostgreSQL. That's it. ``` ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ Your App │────▶│ Rustrak Server │────▶│ PostgreSQL │ │ (Sentry SDK) │ │ (Rust/Actix) │ │ │ └─────────────────┘ └─────────────────┘ └──────────────┘ ``` You point your existing `SENTRY_DSN` at a Rustrak instance and it works. No SDK changes. No vendor lock-in. Your existing error tracking code stays exactly as-is. ## Architecture: Two-Phase Ingestion One design decision we're particularly happy with is the two-phase ingestion model: **Phase 1 — Ingest (synchronous, <50ms):** The SDK sends an event envelope. Rustrak parses it, validates the DSN and auth token, stores the raw event, and returns `200 OK`. This phase is intentionally minimal — we prioritize acknowledging the event over processing it. **Phase 2 — Digest (asynchronous):** A background worker picks up the raw event, calculates its fingerprint, finds or creates the corresponding issue, and updates all the counters. This happens out of band and doesn't affect the SDK's experience. This matters because ingestion latency directly affects your application. A slow error tracker can cascade into production issues. By keeping Phase 1 under 50ms and doing all the heavy work asynchronously, the SDK always gets a fast response. ## Issue Grouping Events get grouped into Issues using a deterministic fingerprinting algorithm: 1. If the event has a custom `fingerprint` field, use that. 2. Otherwise, hash `exception_type + first_line_of_message + transaction`. 3. For log events, hash `log_message + transaction`. 4. Fallback to the event type. The same error happening 1,000 times shows up as one Issue with a count of 1,000. This is the core value of error tracking — noise reduction. ## Memory Footprint One of our goals was a server that runs comfortably in constrained environments. At idle, Rustrak sits at around 30–50MB of RAM. Under load (hundreds of events/second), it stays well under 100MB. The async runtime (Tokio) and the lightweight HTTP framework (Actix-web) make this possible without any manual tuning. For comparison: the minimum recommended RAM for Sentry's self-hosted stack is 3GB. ## Getting Started The fastest way is with Docker — no database setup required, SQLite is included by default. ```yaml # docker-compose.yml services: server: image: rustrak/rustrak-server:latest ports: - "8080:8080" volumes: - rustrak_data:/data environment: - SESSION_SECRET_KEY=${SESSION_SECRET_KEY} - CREATE_SUPERUSER=${CREATE_SUPERUSER} restart: unless-stopped ui: image: rustrak/rustrak-ui:latest ports: - "3000:3000" environment: - RUSTRAK_API_URL=http://server:8080 depends_on: - server restart: unless-stopped volumes: rustrak_data: ``` ```bash SESSION_SECRET_KEY=$(openssl rand -hex 32) CREATE_SUPERUSER=admin@example.com:changeme123 docker compose up -d ``` Open `http://localhost:3000`, log in, create a project, and point your existing Sentry SDK at it: ```javascript Sentry.init({ dsn: "http://@localhost:8080/" }) ``` No SDK changes. It just works. The [Getting Started guide](https://rustrak.dev/getting-started/overview) covers PostgreSQL, environment variables, and production setup in detail.