Error reporting with see()
Report every handled exception with see() so it folds into the errors primitive with a product consequence.
see() is the error-reporting primitive in @shipeasy/sdk. You hand it a caught
exception and a one-sentence consequence — what feature broke and how it
degraded — and the SDK ships a structured issue to your project. Issues group by
fingerprint, surface in the dashboard Errors tab, and auto-file a ticket once
they cross a threshold.
There is no separate error SDK and no second API key. see() rides the same
shipeasy() boot as flags, experiments, and translations — the server uses the
server key, the browser uses the client key.
The shape
import { see } from "@shipeasy/sdk/client"; // or "@shipeasy/sdk/server"
try {
await chargeCard(order, await fetchPrices(order.id));
} catch (e) {
see(e).causes_the("checkout").to("fall back to cached prices").extras({ order_id: order.id });
await chargeCard(order, cachedPrices); // degrade, don't crash
}That call produces an issue titled "… causes the checkout to fall back to
cached prices". The grammar is identical on both sides — the only difference is
the import. There is no .send(): the report dispatches on the next microtask,
fire-and-forget, and see() never throws (it no-ops before shipeasy() has run).
.causes_the() and .causesThe() are the same method — use whichever reads better in your
codebase. .extras() is optional, can be called more than once (keys merge), and accepts string /
number / boolean values.
The consequence is the point
Every see() call names a consequence: a subject (the feature or surface
that was affected) and an outcome (the class of degraded behavior). It reads as
one sentence and becomes the issue title:
{error}causes the{subject}to{outcome}.
The consequence is what turns a stack trace into something you can triage. It is also part of the fingerprint, so two different failures of the same feature group together, and the same exception harming two surfaces splits into two issues.
subject — a bare noun phrase→
The feature, not the mechanism. "checkout", "flag snapshot", "search results". No articles
("the checkout"), no transport ("network request").
outcome — a class of behavior→
What the user gets instead. "fall back to cached prices", "render an empty list". Never
interpolate variable data (a status code, an id) — see below.
Never put variable data in the subject or outcome. A consequence like to("fail with HTTP " + status) mints a fresh issue per status code and shatters your error feed. Keep the consequence
low-cardinality (to("fail with a server error")) and push the specifics into .extras({status} ). Renaming a consequence re-fingerprints the issue — the old row stops growing and a new one
starts.
When you don't need to call see()
The browser SDK auto-captures failed network calls by default — those name a
specific endpoint, so the SDK can write a useful consequence on its own.
Everything else is yours: add an explicit see() wherever you catch an error
and know the product consequence.
Auto-captured for you→
Failed fetch calls (offline / DNS / CORS) and HTTP 5xx responses — each reported against the
specific endpoint that failed, no code from you.
You write the see()→
Everything else — a try/catch where you recover or degrade, and any uncaught error or
rejection. Only your code knows which feature was affected and how; a generic global handler
can't name that, so the SDK deliberately doesn't guess. Report it where you catch it.
Problems that aren't exceptions
When something is wrong but nothing was thrown, report a Violation. The
name is a stable identifier (it's fingerprinted, so keep variable data out of
it); a Violation has no .message() — all context goes in .extras().
if (rows.length > LIMIT) {
see
.Violation("oversized query")
.causes_the("search results")
.to("be trimmed")
.extras({ actual: rows.length, cap: LIMIT });
rows = rows.slice(0, LIMIT);
}Prefer passing the real Error to see() whenever you have one — it preserves
the stack and error type. Reach for Violation only when there is no exception.
Expected throws: mark, don't report
Some throws are control flow, not failures — a parser that tries one decoder
then another, an existence check that throws on "not found." Mark them so
auto-capture stays quiet:
import { see } from "@shipeasy/sdk/server";
try {
return decodeFoo(blob);
} catch (e) {
see.ControlFlowException(e).because("because the blob wasn't a Foo");
return decodeBar(blob);
}Pass the reason to .because() — it must start with "because". Nothing is
reported; you've told the SDK this throw is expected. An optional .extras({…})
tail attaches local debug context (kept on the mark, never sent).
What lands where
From see() to a ticket
- Dispatch.
see()posts a structured event to/collect—sendBeaconin the browser, fire-and-forgetfetchon the server. A 30-second dedup window and a per-session cap keep a loop from flooding the feed. - Fingerprint & group. The Worker normalizes the message and stack, folds in
the consequence, and upserts one row per fingerprint in the
errorstable (with a timeseries in Analytics Engine). Recurrences bump the count; a resolved issue reopens automatically when it happens again. - Auto-file a ticket. When an issue's count crosses the threshold (10 occurrences), Shipeasy files a feedback ticket of type error, with priority scaled by the error kind (network failures rank above caught ones). It dedupes by fingerprint, so one issue files at most one open ticket.
- Triage. The ticket joins your bugs and feature requests in the unified
queue, where
/shipeasy:ops:workcan burn it down like any other item.
Cross-runtime correlation. The browser SDK stamps each same-origin request with an
X-SE-Correlation header; the server reads it back from the request context. When a client-side
5xx and the server exception that caused it both report, Shipeasy joins them — so a broken page
links straight to its root cause on the server.
Rules that keep the feed clean
No generic global wrapper→
A catch-all that funnels every error through one see() can't name the feature, so every issue
gets the same useless consequence. Report at the try/catch that knows what broke.
see() then re-throw is fine→
Report the specific consequence you know, then re-throw to let an outer boundary handle it. The
re-thrown error links back to your report as a caused_by chain — it isn't double-counted.