Exceptional.Client 1.1.0
Exceptional — Client Integration Guide
Audience: an LLM coding assistant integrating an application with an Exceptional server.
You are reading this because someone asked you to send an application's errors to Exceptional. This document is written to be followed literally. It states what to send, what the server does with it, what it will never do, and the specific mistakes that produce a silently broken integration.
If you only read one section, read §1 Rules.
The server address — do not ask for it
https://xcept.ir
This is the Exceptional server this package reports to. It is already the default of
ExceptionalOptions.ServerUrl, so the .NET SDK needs no address at all — AddExceptional with just
an API key is a complete configuration. For every other language, use the literal above; the samples
in §8 already have it filled in.
There is exactly one value you must obtain from the user: the API key (§2). Asking them for the server URL is asking a question this document has already answered.
Set ServerUrl explicitly only in the one case where it is genuinely unknown to you: the user is
running their own Exceptional server on a different host. If they have not said so, they are not.
Contents
1) Rules that decide whether your integration works
These seven rules are the difference between an integration that works and one that appears to work.
1. Your reporting code must never throw into the host application. Wrap every send in a catch-all that does nothing. A telemetry client that crashes the application it monitors is worse than no telemetry at all.
try:
send(payload)
except Exception:
pass # never propagate. Never.
2. Your reporting code must never report its own failures. If a send fails and that failure is itself reported, a network outage becomes an infinite self-amplifying loop that will exhaust the daily quota in minutes. Tag your own HTTP client and discard anything originating in your reporting module.
3. Generate eventId when the error happens, not when you send it.
It is the idempotency key. A client that times out and retries with the same eventId is
de-duplicated by the server. A client that generates a new one on each attempt turns every flaky
connection into inflated counts, and "occurred 201 times" becomes a lie.
4. Never send Authorization, Cookie, or X-Api-Key headers inside request.headers.
Use an allow-list of headers to attach, never a block-list. A block-list silently leaks the next
auth header someone invents. The server strips these five names regardless — but that is a safety
net, not your design.
5. Send the route pattern as transaction, not the concrete URL.
POST /orders/{id}/checkout — not /orders/9912/checkout. The concrete URL produces one group per
order id.
6. Honour Retry-After on 429. Stop permanently on 401 and 403.
429 means slow down; the header tells you by how much. 401/403 mean the key is wrong or the
publisher is disabled — retrying is useless, and a client hammering a rejected key looks like an
attack. Discard the batch.
7. Do not send level below Error unless you have a reason.
The server accepts Trace through Fatal, but volume costs quota. Error and Fatal are the
default useful set. Info is legitimate for heartbeats (see §4, HeartbeatExpectedMinutes).
2) What you need before you start
Ask the user for one value: the API key. The server address is not a question — it is
https://xcept.ir, it is the SDK's built-in default, and it is filled into every sample in this
document.
| Value | Looks like | Where it comes from | Ask the user? |
|---|---|---|---|
| Server URL | https://xcept.ir |
Fixed — the default of ExceptionalOptions.ServerUrl |
No. Only if they say they self-host their own server. |
| API key | exc_a3f9c1d7e2b8_kQ7xR2mN-4pTvA9sYcE6wZ1hJ0bLuF8gXdOiKrSt3Vw |
/Admin/Publishers/{id} → «کلید جدید» |
Yes — it is per-application and unguessable. |
If the user has not given you a key yet, ask for that alone and start writing the integration meanwhile: everything except the key is already determined.
A note on terminology. The admin panel calls each registered application a «کلاینت» (client). The JSON contract, the database and this document call the same thing a publisher — the wire name is stable and will not change. One client = one publisher row = one or more API keys. If the user says "I made a client called Gap1", they mean a publisher named
Gap1.
The key is stored in clear text, so the panel can show it again at any time: open the publisher's
details page and copy it from the keys table (needs the admin.keys.manage permission). The
/Connect page also embeds the real key directly in its snippets.
Store the key the way the host application stores its other secrets — user-secrets, environment
variable, appsettings.Production.json outside source control. Never commit it. The connect-page
snippets show the key inline so they are copy-paste ready; move it into configuration before the
code reaches a repository.
A key inside a shipped binary is not a secret
A key compiled into a
.exe, an APK or a JS bundle is extractable in under a minute. Exceptional is designed on that assumption: an ingest key can only write, it can never read another application's stack traces, and rate limits are per key so abuse burns only that publisher's budget. Tell the user to create one key per distribution channel (android,ios,windows-installer) so revoking one does not break the others.
Verify the key before writing any integration code:
curl -s -H "X-Api-Key: exc_..." https://xcept.ir/api/v1/ping
A 200 with {"ok":true,...} means you are ready. Anything else, stop and fix that first.
3) The endpoints
| Method | Route | Auth | Use it for |
|---|---|---|---|
POST |
/api/v1/events |
X-Api-Key |
One event |
POST |
/api/v1/events/batch |
X-Api-Key |
{"events":[...]}, max 100 |
GET |
/api/v1/ping |
X-Api-Key |
Key check, server clock, effective limits |
GET |
/health |
none | Liveness + database state |
Auth is the X-Api-Key header. A query parameter ?apiKey= is also accepted, only because a
browser navigator.sendBeacon on page unload cannot set headers — it lands in access logs, so do
not use it anywhere else.
/api/v1/ping — call this first, on every start-up
{
"ok": true,
"serverTimeUtc": "2026-08-05T08:36:00.123Z",
"publisher": { "slug": "zarrin-pos", "name": "…", "defaultEnvironment": "Production" },
"limits": { "maxEventsPerMinute": 300, "maxEventsPerDay": 50000,
"maxPayloadBytes": 262144, "maxBatchEvents": 100 }
}
serverTimeUtc matters. Desktop and mobile clocks are routinely wrong by hours. Compute
offset = serverTimeUtc - localNow on your first flush, store it, and apply it to occurredAt.
Without this you cannot tell "it happened three hours ago" from "this laptop's clock is broken", and
neither can the person reading the panel.
Successful response — 202 Accepted
{
"eventId": "3f8a1e2b-77c4-4f0a-9d61-0c2ab5e91f34",
"groupId": 4471,
"fingerprint": "8a3f21c0d4e5b6a7c9012345abcdef67",
"isNewGroup": false,
"warnings": ["۱ هدر حساس حذف شد."]
}
isNewGroup: true→ a genuinely new issue. Worth a line in your own log.warnings→ present only when the server had to correct something. If you see it, read §7 and fix your payload. Its absence is the signal you are done.
4) The payload
Every field is optional. The smallest acceptable payload is:
{ "message": "Access violation at address 0040A1B2" }
Send more when you have it. Everything below is the complete shape.
{
"eventId": "3f8a1e2b-77c4-4f0a-9d61-0c2ab5e91f34", // idempotency key — see rule 3
"occurredAt": "2026-08-05T09:14:22.418Z", // ISO 8601 UTC, clock-corrected
"level": "Error", // Trace|Debug|Info|Warning|Error|Fatal
"message": "Sequence contains no elements",
"logger": "Zarrin.Ordering.CheckoutService",
"release": "2.4.1+881", // send this — it powers regression detection
"environment": "Production",
"handled": false,
"transaction": "POST /orders/{id}/checkout", // route PATTERN — see rule 5
"serverName": "WEB-01",
"sdk": { "name": "exceptional-dotnet", "version": "1.0.0" },
"runtime": { "name": ".NET", "version": "10.0.0" },
"os": { "name": "Windows", "version": "10.0.26100", "architecture": "x64" },
"device": { "model": "SM-A536E", "manufacturer": "Samsung", "screen": "1080x2400" },
"user": { "id": "1042", "username": "ali.rezaei", "email": null, "ipAddress": "5.22.11.9" },
"exception": {
"type": "System.InvalidOperationException",
"module": "System.Linq",
"value": "Sequence contains no elements",
"stackTrace": " at System.Linq.Enumerable.First[T](IEnumerable`1 source)\n at …",
"frames": [ // optional; better than stackTrace if you have it
{ "function": "Zarrin.Ordering.CheckoutService.Apply", "module": "Zarrin.Ordering",
"fileName": "CheckoutService.cs", "lineNumber": 88, "inApp": true }
],
"inner": { "type": "…", "value": "…", "inner": null } // recursive, capped at 10
},
"request": {
"url": "https://shop.zarrin.ir/orders/9912/checkout?ref=sms",
"method": "POST",
"statusCode": 500,
"headers": { "User-Agent": "…", "Accept-Language": "fa-IR" }, // ALLOW-LIST ONLY
"query": { "ref": "sms" },
"bodySnippet": "{\"cartId\":9912}",
"clientIp": "5.22.11.9"
},
"tags": { "tenant": "zarrin", "feature": "checkout" }, // filterable in the panel
"extra": { "cartId": 9912, "retryCount": 2 }, // free-form, shown on the detail page
"breadcrumbs": [
{ "at": "2026-08-05T09:14:19.900Z", "category": "db", "level": "Debug",
"message": "SELECT TOP 1 * FROM Coupons", "data": { "durationMs": 1904 } }
],
"fingerprint": null // grouping override — see §6
}
Field notes that matter
| Field | Why it matters |
|---|---|
release |
Without it, "fixed in 2.4.3, back in 2.6.0" is unavailable and regression alerts read as "it is back" with no version. |
environment |
Part of the fingerprint. Production and Development never share a group. Omitted → the publisher's default. |
exception.inner |
The server walks to the innermost exception to group. Send the whole chain; an AggregateException wrapper on its own groups badly. |
frames[].inApp |
Set it when you know. Otherwise the server infers it from the module prefix (System., Microsoft., mscorlib, netstandard, node:internal, webpack://, node_modules/ are framework). |
breadcrumbs |
Capped at 100, newest kept. Send them in chronological order. |
tags |
Capped at 20 pairs, key ≤64 chars, value ≤200. |
Formats the server is lenient about
You do not need to normalize these — the server does it for you:
- Numbers as strings.
"statusCode": "500"is accepted (Delphi and some PHP clients do this). - Property casing.
Message,messageandMESSAGEall bind. - Unknown properties. Ignored, never rejected. A newer SDK talking to an older server is fine.
- Level aliases.
warn,critical,information,notice,verbose,err,panicall map. - Persian digits in messages. Templated the same as Latin digits.
5) Status codes and how to react to each
The ingest endpoint is deliberately hard to make fail. Almost nothing produces a 4xx.
| Code | Meaning | What your client must do |
|---|---|---|
202 |
Accepted (or de-duplicated) | Nothing. Discard the local copy. |
401 |
Key invalid, expired or revoked | Stop permanently. Discard the batch. Do not retry. |
403 |
Publisher disabled, or Origin not allowed |
Stop permanently. Same as above. |
413 |
Body over the publisher's cap | Do not retry this event unchanged. Trim and drop it. |
429 |
Rate limit or daily quota | Wait Retry-After seconds, then resume. Keep the batch. |
400 |
The body is not JSON at all | A bug in your serializer. Fix it; retrying will not help. |
5xx |
Server problem | Retry with exponential backoff. Keep the batch. |
Why there is no validation
400A client whose report is rejected loses its errors silently, and nobody finds out for months. So: unknown properties are ignored, invalid values fall back to a default, and long fields are truncated. Everything the server corrected comes back in
warningsand appears as a yellow banner on the event page in the panel. The user finds out their payload is wrong by looking at the panel — not from a400your client threw away.
Correct retry logic
send(batch):
response = POST /api/v1/events/batch
if 2xx: discard batch, continue
if 401 or 403: discard batch, stop reporting entirely, log once locally
if 413: discard batch (it will never fit)
if 429: sleep(Retry-After ?? 60), keep batch, retry
if 5xx or network error: spool batch to disk, retry with backoff
6) How grouping works, and how to control it
The server turns many events into few issues. Understanding the rule lets you produce good groups instead of thousands of useless ones.
The algorithm
Client override. If you send a
fingerprintarray, it is used verbatim. Done.Walk to the root exception — the innermost
inner. Wrappers are noise.Components =
publisherId│environment│ roottype│ up to 3 normalized in-app framesFrame normalization — this is where group explosion is prevented:
Before After Why Zarrin.Checkout.Apply in C:\src\x.cs:line 88Zarrin.Checkout.ApplyA line number moves on every recompile. Keeping it means a new group on every deploy. List`1ListGeneric arity is noise <Checkout>b__12_0CheckoutCompiler-generated lambda <Checkout>d__7.MoveNextCheckoutAsync state machine <>c__DisplayClass9_0dropped Closure class g__Inner\|4_1InnerLocal function No usable stack trace (log-only events, Delphi, minified JS) → components become
publisherId │ environment │ level │ templateOf(message), where the template replaces GUIDs, ISO dates, hex addresses, digit runs, quoted strings, paths, emails, IPs and URLs with placeholders. This is what stops «سفارش ۹۹۱ پیدا نشد» and «سفارش ۹۹۲ پیدا نشد» from becoming a million single-member groups.SHA256(join("\n", components))→ 32 hex characters.
The panel shows the exact component list on every group page under «چرا این خطاها با هم گروه شدند؟», so the user can always see why two errors were merged.
When the default grouping is wrong
Too coarse — one group should be several. Send a fingerprint array. {{default}} expands to
everything the algorithm computed, so you add without restating:
// One group per payment gateway instead of one group for all of them
ExceptionalClient.CaptureException(ex, report =>
report.Fingerprint = ["{{default}}", order.PaymentGateway]);
{ "message": "...", "fingerprint": ["{{default}}", "saman"] }
Too fine — several groups should be one. This is fixed in the panel, not in your code: the user opens one group and clicks «ادغام با…». Do not try to solve it client-side.
7) What the server changes about your payload
Every one of these produces a warnings entry and a yellow banner in the panel. Getting a clean
response means your payload arrived intact.
Always removed, not configurable
Headers: Authorization, Cookie, Set-Cookie, X-Api-Key, Proxy-Authorization.
Redacted by key name
Any key in extra, query or bodySnippet matching:
password|passwd|pwd|token|secret|apikey|api_key|authorization|
credit|card|cvv|iban|ssn|nationalcode|national_code|codemelli|رمز|گذرواژه
…has its value replaced with [حذفشده]. Publishers can add their own patterns in the panel.
Truncation limits
| Field | Limit | Behaviour on overflow |
|---|---|---|
message |
2,000 chars | truncated |
exception.stackTrace |
64 KB | 48 KB head + 16 KB tail, middle replaced with a marker |
exception.inner chain |
10 levels | deeper levels dropped |
exception.frames |
100 | extras dropped |
breadcrumbs |
100 | newest kept |
tags |
20 pairs, key ≤64, value ≤200 | extras dropped, values trimmed |
extra |
32 KB | keys dropped until it fits |
request.bodySnippet |
8 KB | truncated |
request.headers |
50 entries, value ≤1 KB | extras dropped |
The stack trace is cut from both ends deliberately: the head holds the exception and your code, the tail holds the entry point that identifies which request this was. A plain head-truncate throws away exactly the frame you needed.
Other corrections
| Situation | Result |
|---|---|
level unrecognized |
→ Error, with a warning |
occurredAt missing |
→ server clock, no warning |
occurredAt more than 10 years past or 1 day future |
→ server clock, with a warning |
environment missing |
→ the publisher's default, no warning |
message missing but exception.value present |
→ exception.value |
message and exception.value both missing |
→ "(بدون پیام)" |
8) Ready-to-adapt integrations
The live, key-filled versions of these are at /Admin/Publishers/{id}/Connect in the panel,
along with a downloadable Postman collection and a «send a test event» button. Prefer sending the
user there; use these when you are writing code.
.NET — with the SDK (preferred)
The package lives on a private feed, not nuget.org, so the source has to be configured before
dotnet add package can find it. Put a NuGet.config next to the .sln:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="nuget.ir" value="https://nuget.ir/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceCredentials>
<nuget.ir>
<add key="Username" value="nugetadmin" />
<add key="ClearTextPassword" value="%NUGET_IR_PASSWORD%" />
</nuget.ir>
</packageSourceCredentials>
<!-- Without this, NuGet asks *both* sources for every package: the private package names leak to
nuget.org, and anyone who publishes `Exceptional.Client` there with a higher version wins the
restore. The most specific pattern wins, not the first one. -->
<packageSourceMapping>
<packageSource key="nuget.ir">
<package pattern="Exceptional.*" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
Keep nuget.org in the list — the private feed is not a mirror, and removing it breaks every
public dependency, including NETStandard.Library on netstandard2.0. Set the password out of
band, never in the file:
[Environment]::SetEnvironmentVariable('NUGET_IR_PASSWORD','<password>','User')
Then:
dotnet add package Exceptional.Client
The first build after installing writes Exceptional-Client-Guide.md — this document — into
your project folder, so the contract is available to you and to any coding assistant working in the
repository. It is written once and never overwritten; edit or delete it freely. To stop it being
created at all:
<PropertyGroup>
<ExceptionalClientInstallGuide>false</ExceptionalClientInstallGuide>
</PropertyGroup>
// ASP.NET Core / Generic Host
builder.Services.AddExceptional(o =>
{
// No ServerUrl here on purpose: it already defaults to https://xcept.ir. Set it only to
// report to a self-hosted server.
o.ApiKey = builder.Configuration["Exceptional:ApiKey"]!;
o.Environment = builder.Environment.EnvironmentName;
o.Release = typeof(Program).Assembly.GetName().Version?.ToString();
o.MinimumLevel = ExceptionalLevel.Error;
o.CaptureRequestBody = false; // opt-in; the body is where PII lives
o.BeforeSend = report => Scrub(report); // last chance to redact or drop
});
app.UseExceptional(); // early, before UseRouting
That is the whole integration. AddExceptional also installs an ILoggerProvider, so every
LogError/LogCritical the application already writes starts reporting with no call sites touched.
For a console app, WinForms, WPF or a Windows service:
ExceptionalClient.Init(new ExceptionalOptions
{
ApiKey = "exc_...", // the only value that has to come from the user
Release = "5.2.0"
});
// AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException are hooked automatically.
Only when the user runs their own server does the address enter the code at all:
ExceptionalClient.Init(new ExceptionalOptions
{
ServerUrl = "https://errors.internal.example", // self-hosted; otherwise leave it alone
ApiKey = "exc_..."
});
UI-framework hooks are one line each and are not wired automatically, because doing so would drag those frameworks into the package:
// WPF
Application.Current.DispatcherUnhandledException += (_, e) => ExceptionalClient.CaptureException(e.Exception);
// WinForms
Application.ThreadException += (_, e) => ExceptionalClient.CaptureException(e.Exception);
// MAUI
MauiExceptions.UnhandledException += (_, e) => ExceptionalClient.CaptureException((Exception)e.ExceptionObject);
The SDK's own guarantees: it never throws, never reports its own errors, spools to
%LOCALAPPDATA%\Exceptional\{app}\spool\ when offline (20 MB cap, oldest dropped), batches every
50 events or 5 seconds, and calibrates its clock against /ping.
.NET — without the SDK
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
try
{
await http.PostAsJsonAsync($"{serverUrl}/api/v1/events", new
{
eventId = Guid.NewGuid(),
message = ex.Message,
level = "Error",
environment = "Production",
release = "1.0.0",
exception = new
{
type = ex.GetType().FullName,
value = ex.Message,
stackTrace = ex.StackTrace,
inner = ex.InnerException is null ? null : new { type = ex.InnerException.GetType().FullName, value = ex.InnerException.Message }
}
});
}
catch { /* rule 1: never throw into the host */ }
Browser JavaScript
const EXC_URL = "https://xcept.ir/api/v1/events";
const EXC_KEY = "exc_...";
function report(message, error) {
fetch(EXC_URL, {
method: "POST",
keepalive: true, // survives page unload
headers: { "X-Api-Key": EXC_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
eventId: crypto.randomUUID(),
message: String(message),
level: "Error",
environment: "Production",
exception: error && { type: error.name, value: error.message, stackTrace: error.stack },
request: { url: location.href },
os: { name: navigator.platform }
})
}).catch(() => {}); // rule 2: never report a failure to report
}
window.onerror = (msg, src, line, col, err) => report(msg, err);
window.onunhandledrejection = (e) => report("unhandledrejection", e.reason);
The browser needs one extra step. The publisher's
AllowedOriginsmust contain your site's origin, or the server answers403. Set it at/Admin/Publishers/{id}→ «Originهای مجاز». A publisher with an empty list rejects all browser traffic — that is the default, on purpose.
Node.js
async function report(err) {
try {
await fetch(`${SERVER}/api/v1/events`, {
method: "POST",
headers: { "X-Api-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({
eventId: crypto.randomUUID(),
message: err.message,
level: "Error",
environment: process.env.NODE_ENV ?? "Production",
exception: { type: err.name, value: err.message, stackTrace: err.stack },
runtime: { name: "node", version: process.version }
})
});
} catch { /* swallow */ }
}
process.on("uncaughtException", (err) => { report(err).finally(() => process.exit(1)); });
process.on("unhandledRejection", (reason) => report(reason instanceof Error ? reason : new Error(String(reason))));
Python
import json, sys, traceback, urllib.request, uuid
URL, KEY = "https://xcept.ir/api/v1/events", "exc_..."
def report(exc, level="Error"):
payload = json.dumps({
"eventId": str(uuid.uuid4()),
"message": str(exc),
"level": level,
"environment": "Production",
"exception": {
"type": type(exc).__name__,
"value": str(exc),
"stackTrace": "".join(traceback.format_exception(exc)),
},
"runtime": {"name": "python", "version": sys.version.split()[0]},
}).encode("utf-8")
request = urllib.request.Request(URL, data=payload, headers={
"X-Api-Key": KEY,
"Content-Type": "application/json; charset=utf-8",
})
try:
urllib.request.urlopen(request, timeout=5).read()
except Exception:
pass # rule 1
sys.excepthook = lambda t, v, tb: report(v, "Fatal")
# As a logging handler as well:
import logging
class ExceptionalHandler(logging.Handler):
def emit(self, record):
if record.exc_info:
report(record.exc_info[1])
logging.getLogger().addHandler(ExceptionalHandler(level=logging.ERROR))
Delphi
uses System.Net.HttpClient, System.JSON, System.Classes, System.SysUtils;
procedure ReportError(const AMessage: string);
var
Http: THTTPClient;
Json: TJSONObject;
Body: TStringStream;
begin
Http := THTTPClient.Create;
Json := TJSONObject.Create;
try
Json.AddPair('message', AMessage);
Json.AddPair('level', 'Fatal');
Json.AddPair('environment', 'Production');
Json.AddPair('release', '5.2.0');
// TEncoding.UTF8 is mandatory. Without it Persian text is mangled — the classic Delphi bug.
Body := TStringStream.Create(Json.ToJSON, TEncoding.UTF8);
try
Http.CustomHeaders['X-Api-Key'] := 'exc_...';
Http.ContentType := 'application/json; charset=utf-8';
Http.Post('https://xcept.ir/api/v1/events', Body);
finally
Body.Free;
end;
except
// rule 1: never surface a reporting failure to the user
end;
Json.Free;
Http.Free;
end;
// Application.OnException := ReportUnhandled;
A Delphi client usually has no usable stack trace. That is fine — the message template path groups
it (see §6.5), which is why Access violation at address 0040A1B2 and …0040B7C4 land in one group.
curl / shell
curl -X POST "https://xcept.ir/api/v1/events" \
-H "X-Api-Key: exc_..." \
-H "Content-Type: application/json; charset=utf-8" \
-d '{"message":"backup job failed","level":"Fatal","environment":"Production"}'
9) Checklist before you call it done
Run through this literally. Each line is a real failure someone has shipped.
-
GET /api/v1/pingreturns200with the credentials you configured. - You did not stop to ask the user for a server address. It is
https://xcept.ir, and in .NET you did not write it down at all — the default covers it. - The API key is read from configuration, not a string literal in source.
- Every send path is wrapped in a catch-all that swallows.
- Failures inside the reporting code are not reported.
-
eventIdis generated where the error occurs, not where it is sent. -
429waits forRetry-After;401/403stop permanently. -
request.headersuses an allow-list. -
transactionis the route pattern, not the concrete URL. -
releaseandenvironmentare populated. - You triggered a real error and saw it in
/Admin/Errorsin the panel. - You triggered it twice and saw one group with count 2 — not two groups.
- The
202response carried nowarnings, or you fixed what it reported. - For a browser integration,
AllowedOriginscontains your origin. - For a desktop/mobile integration, unsent events survive a restart.
- The user knows which key belongs to which distribution channel.
10) Troubleshooting: symptom → cause
| Symptom | Cause | Fix |
|---|---|---|
401 on every request |
Key wrong, revoked, expired, or missing the exc_ prefix |
Re-copy it from the panel; if lost, rotate |
403, server-side client |
Publisher is disabled | /Admin/Publishers/{id} → «فعال» |
403, browser only |
Origin not in the publisher's allow-list |
Add it to «Originهای مجاز» |
413 |
Body over the publisher's MaxPayloadBytes (default 256 KB) |
Trim bodySnippet, extra, breadcrumbs |
429 immediately |
Rate limit, default 300/minute per key | Batch your sends; respect Retry-After |
429 after a while, all day |
Daily quota exhausted (default 50,000) | Check the dashboard's «دورریخته امروز» card |
| A new group on every deploy | You are sending pre-normalized frames including line numbers | Send raw stackTrace and let the server normalize |
| Thousands of one-event groups | Message with unique ids and no stack trace | Send exception.stackTrace, or use fingerprint |
| Two groups that are one bug | Different code paths, different in-app frames | Merge them in the panel («ادغام با…») |
| One group that should be two | Same frames, different context | Send fingerprint: ["{{default}}", context] |
| Counts higher than reality | eventId regenerated on each retry |
Generate it once, at capture time |
clientIp is always ::1 |
The server is behind a proxy without UseForwardedHeaders |
Server-side config, see docs/Deployment-IIS.md |
| Yellow banner on every event | The sanitizer is correcting your payload | Read the banner text; it names each correction |
| Nothing arrives, no errors either | Your catch-all is swallowing a configuration failure | Log once locally before swallowing |
| Alerts never arrive | No rule, no notifier target, or the global kill switch is off | /Admin/NotificationRules, /Admin/Settings |
See also
Ingest-Api.md— the raw HTTP contract, written for someone who has never seen .NETGrouping.md— the fingerprint algorithm with worked examplesNotifier-Plugins.md— adding a new alert channelDeployment-IIS.md— running the server on Windows/IIS/Admin/Publishers/{id}/Connect— live snippets with the key filled in, and a test-event button
No packages depend on Exceptional.Client.
.NET 10.0
- No dependencies.
.NET Standard 2.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- System.Text.Json (>= 10.0.0)