A failure your code swallows never reaches Déjà.
Déjà scores the failures that reach Sentry as issues, and can attribute one only when its title names the field that broke. Three common patterns keep a failure out of Sentry: a consumer that dead-letters, validation that returns a result, and a downstream call that is caught and logged. For each, the change that makes the failure visible, and what it costs.
Written for the engineer whose incidents end in a no-attribution receipt — or in no receipt at all.
A Sentry issue, and one line of it decides.
What the pipeline does with a Sentry delivery, one step per row, with the function that does it. A log line is not a Sentry issue, and nothing here reads one.
- Deliveries scored
Sentry's issue and error webhooks, the two the Sentry connection asks you to enable. An alert-rule notification is logged and not scored, so a Sentry alert rule is not a way to forward a failure.
sentry/event-router.ts · routeSentryEvent · sentry/alert-handler.ts · handleAlertEvent
- What scoring is given
The issue's title, id, project slug, event type and time. Tags, extra data, the user and the stack trace are not scoring inputs.
sentry/issue-handler.ts · handleIssueEvent · sde-phase1-queue.ts · runSentryPhase1Job
- The title
The issue title as Sentry sends it, which Sentry builds from the exception's type, a colon, and the first line of its message. A delivery with no title falls back to the first exception's type and message.
sentry/event-router.ts · exceptionTitle
- Error type
The title up to its first colon or space. It scores the error-type factor, W4: a missing-field name (KeyError, AttributeError, MissingFieldError) matches a removed or renamed field; a type name (TypeError, ValueError, ValidationError) matches a changed type, except a JavaScript TypeError from reading a property, which counts as a missing field.
sde-phase1-queue.ts · runSentryPhase1Job · sde/ccs-math-engine.ts · classifyError
- The field
The property a JavaScript engine names in (reading 'x'), Cannot read property 'x' of, or Safari's evaluating 'obj.x'. Otherwise the first quoted word in the title: two or more letters, digits, _, . or -. If that word is capitalised like a class name ('NoneType'), no quoted word is used. Otherwise a bare Python KeyError: x.
sde-phase2-deduction.ts · extractMissingField
- No field found
No change is scored. The receipt is a no-attribution receipt (R1-N) whose reason is no_field_extracted.
sde-phase2-deduction.ts · runSdePhase2DeductionInner
- Candidates
Recorded changes to that field, under the name as written or its snake_case or camelCase form, from the last 30 days.
sde-phase2-deduction.ts · runSdePhase2DeductionInner
- Service zone
The zone named like the Sentry project's slug. If no zone has that name, one of the vault's other active zones.
sde-phase1-queue.ts · resolveSentryServiceZone
- Repeats
Each Sentry issue is scored at most once per release. Repeats are suppressed for 90 days when the event carries a release, and for 7 days when it does not.
sentry/event-dedup.ts · buildIssueDeliveryId · sentry/event-dedup.ts · isDuplicate
Put the field in the title, quoted, and first.
A runtime's own error rarely names the field that went missing, so name it yourself. One helper does it for all three patterns below.
import * as Sentry from "@sentry/node"
// Sentry titles this "MissingFieldError: 'total_cents' missing
// in orders.created"; Déjà reads the field from that title.
export function reportFieldFailure(
field: string,
kind: "missing" | "wrong-type",
where: string,
extra: Record<string, unknown> = {},
): void {
const what = kind === "missing" ? "missing" : "has the wrong type"
const err = new Error(`'${field}' ${what} in ${where}`)
err.name = kind === "missing" ? "MissingFieldError" : "FieldTypeError"
Sentry.captureException(err, {
// One Sentry issue per field, not one per line of code.
fingerprint: ["field-failure", where, field, kind],
extra,
})
}The error's name sets the error-type factor: a MissingFieldError is scored against a removed or renamed field, a FieldTypeError against a changed type.
The fingerprint gives each field its own Sentry issue. Without it Sentry groups by stack trace, every field reported from one line lands in one issue, and Déjà reads that issue's title once.
Report the field's own name, the last segment of its path: customer.email is looked for as one name. And report the name, never the value — the title is stored.
| Title Sentry sends | Field Déjà takes | Scored against | Why |
|---|---|---|---|
| MissingFieldError: 'total_cents' missing in orders.created | total_cents | A removed or renamed field | What the helper sends for a missing field. |
| FieldTypeError: 'total_cents' has the wrong type in orders.created | total_cents | A changed type | What the helper sends for a wrong type. |
| KeyError: 'total_cents' | total_cents | A removed or renamed field | Python's own error for a missing key already names it. |
| TypeError: Cannot read properties of undefined (reading 'trim') | trim | A removed or renamed field | JavaScript names the property read from undefined: the one after the missing field. |
| ValidationError: 'total_cents' is required | total_cents | A changed type | The right field, but a validation error is scored against a changed type, not a removed field. |
| ZodError: [ | none | Nothing — an R1-N | A captured ZodError: its message is JSON, and the title keeps only the first line. |
| Error: order 'A-1043' failed: 'total_cents' missing | A-1043 | A removed or renamed field | Only the first quoted word is read. |
The consumer that dead-letters, and says nothing.
The handler throws, the catch moves the message to a dead-letter queue, and the only record is a log line. Sentry never hears of it, so Déjà never does — not even as a no-attribution receipt.
async function onMessage(msg: QueueMessage) {
try {
await handleOrder(JSON.parse(msg.body))
} catch (err) {
logger.warn({ err, messageId: msg.id }, "dead-lettered")
await deadLetter.send(msg)
}
}async function onMessage(msg: QueueMessage) {
try {
await handleOrder(JSON.parse(msg.body))
} catch (err) {
// Before the send, so a failing DLQ cannot take the report with it.
Sentry.captureException(err, { extra: { messageId: msg.id } })
logger.warn({ err, messageId: msg.id }, "dead-lettered")
await deadLetter.send(msg)
}
}The message id goes in extra data, for whoever triages; Déjà does not score it. What Déjà can attribute depends on err. In JavaScript, reading a missing field returns undefined without an error; the TypeError comes when something is read from that undefined, and names what was read (the trim row above). Validate at the top of the handler, as in pattern two, and dead-letter what it rejects.
try:
# order["total_cents"] raises KeyError: 'total_cents'
handle_order(json.loads(msg.body))
except Exception as err:
sentry_sdk.capture_exception(err)
dead_letter.send(msg)Python's KeyError names the key itself, so capturing it is enough — if you index with [ ]. With .get() the missing value is None, and the error that follows usually quotes 'NoneType' first, which Déjà skips as a class name.
One Sentry event per dead-lettered message. Usually the smallest of the three: the dead-letter rate is low until a producer breaks, and then the burst groups into Sentry issues, which Déjà scores once each per release. Every event still counts against your Sentry quota.
Validation that returns a result, and nothing is thrown.
safeParse returns { success: false } instead of throwing, and the caller logs a warning and moves on. No exception, no Sentry issue.
function onOrder(payload: unknown) {
const parsed = OrderCreated.safeParse(payload)
if (!parsed.success) {
logger.warn({ issues: parsed.error.issues }, "order skipped")
return
}
return fulfil(parsed.data)
}function onOrder(payload: unknown) {
const parsed = OrderCreated.safeParse(payload)
if (!parsed.success) {
const issue = parsed.error.issues[0]
const field = String(issue?.path.at(-1) ?? "payload")
const missing =
issue?.code === "invalid_type" && issue.received === "undefined"
reportFieldFailure(
field,
missing ? "missing" : "wrong-type",
"orders.created",
)
return
}
return fulfil(parsed.data)
}Report the field, not the ZodError. Its message is JSON, so the title Sentry builds from its first line is ZodError: [, and the first quoted word in the rest is code. The missing or wrong-type split chooses the error's name, so a dropped field is scored against a removed field and a retyped one against a changed type.
One event per rejected payload — the most of the three, because when a producer drops a field every message fails. Déjà needs one: the fingerprint makes it one Sentry issue per field, scored once per release. To spare your quota, report the first rejection per field per minute and count the rest in a metric.
The downstream call that is caught, and quietly logged.
A call to another service is wrapped in try/catch, the failure is logged as a warning, and the caller gets a fallback. The fallback is deliberate. The silence is not.
async function taxIdFor(invoiceId: string) {
try {
const invoice = await billing.getInvoice(invoiceId)
return invoice.tax_id.trim()
} catch (err) {
logger.warn({ err, invoiceId }, "billing lookup failed")
return null
}
}async function taxIdFor(invoiceId: string) {
try {
const invoice = await billing.getInvoice(invoiceId)
if (invoice.tax_id === undefined) {
reportFieldFailure("tax_id", "missing", "billing.getInvoice", {
invoiceId,
})
return null
}
return invoice.tax_id.trim()
} catch (err) {
Sentry.captureException(err, { tags: { downstream: "billing" } })
logger.warn({ err, invoiceId }, "billing lookup failed")
return null
}
}The behaviour does not change: the caller still gets null. Check the field before you use it — on a missing tax_id, invoice.tax_id.trim() throws (reading 'trim'), which names trim. In the catch, capture err itself: wrapping it in a new error with a summary message replaces the title Déjà reads. The tag is for you; Déjà does not score tags.
Every caught failure becomes an event, routine ones included — timeouts, 404s, retries that later succeed. Most name no field, so each Sentry issue they form ends in an R1-N with no_field_extracted, not an attribution. Always report the field check; capture the rest if you want them in Sentry for their own sake.
Scored once per issue, signed either way.
- Scoring
- At most once per Sentry issue and release, however many events the issue collects.
- Receipts
- An attribution (R1) counts toward the monthly allowance, which is advisory: signing never stops. A low-confidence (R1-L) or no-attribution (R1-N) receipt never counts. See pricing.
- Storage
- The title becomes the incident's title in Déjà, so it should carry a field's name and never its value. What else is kept from a Sentry event is on the engine page.
Other sources
Datadog and Splunk On-Call alerts are scored too. In a Splunk On-Call alert, Déjà looks for the field in the alert's display name, entity_display_name, by the same rule. A Datadog alert cannot name the field: Déjà's intake keeps no alert title or message, so what it scores is a service check's name or a fixed label such as Datadog monitor.alert. Report a failure you want attributed to Sentry.
Visible is not attributed. Déjà attributes a failure only when a recorded change to the field it names clears the threshold — the engine publishes the rule, and the scope says what never will be. A failure that reaches scoring and does not clear the threshold still ends in a signed receipt — low confidence (R1-L) or no attribution (R1-N) — that says why.