# Migrating from Bugsnag or legacy clients

> **For agents:** nothing to call — these are ingest endpoints, not API routes. `list_keys(org="acme", project="checkout-api")` gives the key both of them authenticate with.

The slowest part of leaving an error tracker is rarely the dashboard. It is the fleet of services already posting somewhere else, each with its own deploy, its own owner, and its own reason not to be touched this quarter.

So both of the endpoints below exist for one reason: **changing a URL should be the whole migration.**

## Bugsnag notifiers

Point your notifier's endpoint at Bugwatch and set its API key to your Bugwatch public key. Nothing else changes — not the notifier, not its version, not a line of your code.

```js
Bugsnag.start({
  apiKey: 'YOUR_BUGWATCH_PUBLIC_KEY',
  endpoints: {
    notify: 'https://ingest.bugwatch.io/notify',
    sessions: 'https://ingest.bugwatch.io/notify',
  },
})
```

The public key is the same one in your DSN — the part before the `@`. A disabled key is disabled here too.

### What maps onto what

Bugsnag events become ordinary Bugwatch events, so grouping, scrubbing, filters, alert rules and issue assignment all work on them with nothing special configured.

| Bugsnag | Becomes | Note |
|---|---|---|
| `exceptions[].errorClass` / `message` | The exception type and value | Older notifiers send `errorMessage`; both are read |
| `exceptions[].stacktrace` | The stack trace | **Reversed** — see below |
| `stacktrace[].inProject` | `in_app` | Drives which frames are yours |
| `severity` | Level | `error`, `warning`, `info`. An unhandled error stays `error` |
| `unhandled` | `handled` on the mechanism | Absent when the notifier did not say |
| `groupingHash` | The issue fingerprint | Your existing merges survive the move |
| `app.version` / `app.releaseStage` | Release / environment | |
| `context` | Transaction | The route or job name |
| `user` | User | `name` becomes the username |
| `metaData` | Extra | Scrubbed like any other custom data |
| `breadcrumbs` | Breadcrumbs | |
| `device.osName` / `osVersion` | The OS context | |

**The stack is reversed on purpose.** Bugsnag lists the throwing frame first and we list it last. Grouping reads the last in-app frame as the culprit, so a stack passed through unreversed would fingerprint every issue on its entry point — and an entire application's errors would arrive as one issue. If you are comparing a Bugsnag issue with ours side by side, this is why the frames look upside down.

**`metaData` becomes extra data, never tags.** Tags are a low-cardinality dimension used for aggregation; a custom field with a customer id in it would make them useless. Extra data is shown on the event and scrubbed by your project's rules.

### Running both at once

Nothing stops you sending to Bugsnag and Bugwatch in parallel while you compare them — two notifier instances, two keys. Issue counts will not match exactly, because the two products group differently; what should match is *which* problems appear.

## Pre-envelope Sentry clients

Older Sentry SDKs POST a bare event JSON to `/api/{project}/store/` rather than an envelope. That endpoint is supported with the same DSN authentication as the envelope route, so a client too old to upgrade still reports.

```
POST https://ingest.bugwatch.io/api/42/store/
X-Sentry-Auth: Sentry sentry_key=YOUR_PUBLIC_KEY, sentry_version=7
```

The event's own `event_id` is honoured when it carries one, so a retried POST lands on the same event rather than creating a second issue. A body with `"type": "transaction"` is treated as a transaction, exactly as it would be inside an envelope.

Current SDKs should use `/api/{project}/envelope/` — it carries sessions, attachments and client reports, which the legacy endpoint has no way to express.

## Running both trackers at once (dual-send)

Before switching for good, send every event to both: the tracker you are on keeps working exactly as it does, and Bugwatch receives the same events so you can compare them on your own traffic. For JavaScript and Node, `dualSend` wraps the SDK's own transport:

```js
import * as Sentry from "@sentry/node";            // or @sentry/browser
import { dualSend } from "@bugwatch/node";       // or @bugwatch/browser

Sentry.init({
  dsn: process.env.OLD_DSN,                         // unchanged
  transport: dualSend(Sentry.makeNodeTransport, process.env.BUGWATCH_DSN),
});
```

In the browser it is `dualSend(Sentry.makeFetchTransport, BUGWATCH_DSN)`. When you have decided, remove the wrapper and point `dsn` at Bugwatch, or swap the two DSNs to keep the old tracker as the mirror for a while.

**The mirror never costs the tracker you are on anything.**

- It is a separate transport, with its own queue and its own rate limits. A mirror that is slow, down, or answering 429 never delays or drops an event bound for your current tracker, and its answer is never the one the SDK sees.
- Its drops are not reported through the SDK. A rate-limited mirror would otherwise send your current tracker a client report saying events were dropped that it never saw dropped.
- Client reports stay with your current tracker, because they describe its drops.
- `flush()` gives both a chance to drain and returns the current tracker's answer.

This is checked on every release with the real SDK. One test runs it against two servers, the mirror starting with a 429. The end-to-end run sends an event from an app still on its old tracker and requires it to arrive both there and as an issue here.

Issue counts will not match exactly, because the two products group differently. What should match is *which* problems appear. Events count against your Bugwatch quota as usual while you mirror.

**Other SDKs:** most accept a custom transport. Send each envelope, unchanged, to the second DSN's envelope endpoint as well: `{scheme}://{host}/api/{project}/envelope/?sentry_key={key}&sentry_version=7`, with any path before the project id kept. Never let the second send block or fail the first.

## Bringing your issue backlog with you

Changing where events go is the easy half. The hard half is the backlog you have already triaged — the issues somebody resolved, muted, or assigned — because arriving on a new tracker with all of that reset means re-triaging a year of decisions in week one.

The importer carries **triage**, not history. Events are not copied: an issue arrives with its status, its assignee, its first and last seen dates, and its counts recorded as *what the old tracker said*. Nothing pretends the events came too.

### Running it

**In the dashboard:** Settings → General → **Import issues**, for owners and admins. Choose the export file and press **Preview**: every page is checked as a dry run and you get one report for the whole file. **Import** is enabled only once you have read it. The file is read in your browser and sent page by page; nothing about the old tracker is stored but the issues themselves. Set **Imported from** once and keep it: it is part of how a second run recognises issues it already brought over.

The file can be a JSON array of issues, an object with an `issues` array, or one JSON value per line, which is what you get by saving every page of an issue-list API one after another. Each issue's `id`, `title`, `culprit`, `status`, `level`, `firstSeen`, `lastSeen`, `count`, `userCount`, `permalink` and its assignee (an `assignee` string, or an `assignedTo` object with an email) are read. A row that is not an issue, has no `id`, or repeats an issue already in the file is named in the summary rather than dropped.

**Exporting from the old tracker** happens on your machine, with your own token. Most trackers' issue-list endpoint answers one JSON page at a time and points to the next page in a `Link` header. This saves every page, one per line:

```bash
url="https://old-tracker.example/api/0/projects/ORG/PROJECT/issues/?limit=100"
: > issues.ndjson
while [ -n "$url" ]; do
  curl -sS -D headers.txt -H "Authorization: Bearer $OLD_TOKEN" "$url" >> issues.ndjson
  echo >> issues.ndjson
  url=$(grep -i '^link:' headers.txt | grep -o '<[^>]*>; rel="next"; results="true"' | sed 's/^<\([^>]*\)>.*/\1/')
done
```

> **For agents:** `import_issues(org="acme", project="checkout-api", source="sentry", issues=[…])`. It defaults to a dry run — call it once to read the coverage report, then again with `dryRun=false`.

**You export the issues; Bugwatch never asks for a token to your old tracker.** Whatever the old product gives you — its API, its CSV export, a script you already have — the issues arrive in the request body. Nothing here holds a credential to a system you are leaving.

```bash
curl -X POST https://api.bugwatch.io/v1/orgs/acme/projects/checkout-api/import-issues \
  -H "Authorization: Bearer $BUGWATCH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "sentry",
    "issues": [
      {"id": "4512", "title": "TypeError: x is not a function", "status": "resolved",
       "assignee": "dana@acme.example", "firstSeen": "2025-11-02T09:14:00Z",
       "count": 1204, "fingerprint": "8f2c…", "permalink": "https://…/issues/4512"}
    ]
  }'
```

`id` is the only required field per issue; `title`, `culprit`, `status`, `level`, `firstSeen`, `lastSeen`, `count`, `userCount`, `assignee`, `fingerprint` and `permalink` are used when present. Up to **500 issues per call** — page through a larger backlog, and re-running a page is safe (see below). Requires the `event:write` scope.

**`dryRun` defaults to `true`.** The first call changes nothing and returns the same coverage report the real run would, so you can read what will happen — how many merge, how many get synthetic fingerprints, which assignees do not match — before anything is written. Set `"dryRun": false` to commit.

**An issue that already exists here is never overwritten.** If the fingerprint already has an issue in this project, that issue was made by your own events and whatever triage it carries was decided *in this product*; letting an import stamp over it would undo a decision made here in favour of one made elsewhere. Those are reported as already known rather than as errors, which is also why re-running a page is safe.

### What survives, and what does not

| Carried | Not carried |
|---|---|
| Status — resolved, ignored, unresolved | The events themselves |
| Assignee, when the email matches a member here | An assignee who is not a member |
| First seen, last seen | Comments and activity |
| Event and user counts, as source metadata | Live counters (see below) |
| A link back to the original issue | |

**Counts are metadata, not counters.** Exact counts here come from the pipeline that produced them, and an imported number is a fact about somewhere else. It is shown as "1,204 events at the old tracker" and never added to this issue's own count, which starts where your events start.

### Whether an imported issue merges with new events

This is the question worth understanding before you run it.

An imported issue merges when its `fingerprint` is **the fingerprint your SDK sends**: the values your code sets with `scope.setFingerprint([...])` (or `event.fingerprint` in `beforeSend`). The issue is stored under exactly the hash a live event carrying that fingerprint is grouped by, so the next time that error happens here it lands **on the imported issue**, and your triage keeps applying. This is verified end to end on every release: an issue is imported, a real SDK sends an error with its fingerprint, and the event must be stored under the imported issue with no second issue opened.

**Do not pass the old tracker's own grouping hashes.** A tracker computes those with its own algorithm from the stack trace, and this product groups with its own; the two never produce the same value, so an issue imported under one would be reported as mergeable and never merge. Leave `fingerprint` out unless your code sets it.

Without one, the key is invented, and a live occurrence of the same error opens a **separate** issue beside the imported one. The importer counts these and tells you how many: they are labelled `synthetic` in the coverage report. A high number is normal for a codebase that relies on default grouping, and it is a limit of what can be carried, not a fault. Know it before you look at your issue list and conclude the import duplicated everything.

Fingerprints are never derived from the issue **title**. Titles get localised, truncated and edited; two trackers agreeing on one is luck, and an issue merged by luck is one nobody can explain later.

### The coverage report

Every import ends with a count and a denominator, plus a line per issue that did not come across cleanly:

```
4,180 of 4,200 imported · 20 skipped · 63 synthetic fingerprints
  issue:9931  duplicate_fingerprint   same grouping key as issue:8812; skipped
  issue:9944  assignee_not_matched    "dana@old.example" is not a member; imported unassigned
  issue:9951  unknown_status          status "review" is not recognised; imported as unresolved
```

An unrecognised status always becomes **unresolved**, never resolved. Burying an open issue on import is triage destroyed rather than carried, and it is the one failure that would be invisible.

## What to check after switching

- **Events arrive.** The issue list is the fastest confirmation; it updates within seconds.
- **The release and environment look right.** These come from `app.version` and `app.releaseStage`, which not every notifier is configured to send.
- **Your quota.** Bugwatch applies your organization's quota to these endpoints exactly as it does to the envelope route, so a migration that doubles your volume shows up as back-pressure rather than as a surprise on the invoice.
- **Your inbound filters.** Deny rules for user agents, origins and releases apply here too.

## See also

- [SDKs & platforms overview](https://docs.bugwatch.io/sdks/overview.md) — the DSN format and the platform table
- [Filters and scrubbing](https://docs.bugwatch.io/product/filters-and-scrubbing.md) — what never leaves your account
