Exceptional.Client 1.0.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.


Contents

§ Section
1 Rules that decide whether your integration works
2 What you need before you start
3 The endpoints
4 The payload
5 Status codes and how to react to each
6 How grouping works, and how to control it
7 What the server changes about your payload
8 Ready-to-adapt integrations
9 Checklist before you call it done
10 Troubleshooting: symptom → cause

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 two values. Do not guess either.

Value Looks like Where it comes from
Server URL https://errors.example.ir The person running the panel
API key exc_a3f9c1d7e2b8_kQ7xR2mN-4pTvA9sYcE6wZ1hJ0bLuF8gXdOiKrSt3Vw /Admin/Publishers/{id} → «کلید جدید»

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 both values before writing any integration code:

curl -s -H "X-Api-Key: exc_..." https://errors.example.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, message and MESSAGE all bind.
  • Unknown properties. Ignored, never rejected. A newer SDK talking to an older server is fine.
  • Level aliases. warn, critical, information, notice, verbose, err, panic all 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 400

A 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 warnings and 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 a 400 your 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

  1. Client override. If you send a fingerprint array, it is used verbatim. Done.

  2. Walk to the root exception — the innermost inner. Wrappers are noise.

  3. Components = publisherIdenvironment │ root type │ up to 3 normalized in-app frames

  4. Frame normalization — this is where group explosion is prevented:

    Before After Why
    Zarrin.Checkout.Apply in C:\src\x.cs:line 88 Zarrin.Checkout.Apply A line number moves on every recompile. Keeping it means a new group on every deploy.
    List`1 List Generic arity is noise
    <Checkout>b__12_0 Checkout Compiler-generated lambda
    <Checkout>d__7.MoveNext Checkout Async state machine
    <>c__DisplayClass9_0 dropped Closure class
    g__Inner\|4_1 Inner Local function
  5. 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.

  6. 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 =>
{
    o.ServerUrl   = "https://errors.example.ir";
    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
{
    ServerUrl = "https://errors.example.ir",
    ApiKey    = "exc_...",
    Release   = "5.2.0"
});
// AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException are hooked automatically.

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://errors.example.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 AllowedOrigins must contain your site's origin, or the server answers 403. 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://errors.example.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://errors.example.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://errors.example.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/ping returns 200 with the credentials you configured.
  • 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.
  • eventId is generated where the error occurs, not where it is sent.
  • 429 waits for Retry-After; 401/403 stop permanently.
  • request.headers uses an allow-list.
  • transaction is the route pattern, not the concrete URL.
  • release and environment are populated.
  • You triggered a real error and saw it in /Admin/Errors in the panel.
  • You triggered it twice and saw one group with count 2 — not two groups.
  • The 202 response carried no warnings, or you fixed what it reported.
  • For a browser integration, AllowedOrigins contains 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 .NET
  • Grouping.md — the fingerprint algorithm with worked examples
  • Notifier-Plugins.md — adding a new alert channel
  • Deployment-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.

Version Downloads Last updated
1.1.0 1 8/7/2026
1.0.0 3 8/6/2026