# The Problem Reports kits — one app tells another about its problems, and the other follows each to the end

> `Osysharp.ProblemReports` (the wire and the receiving side) and `Osysharp.ProblemReports.Sending` (the reporting side) are a public wire between two apps. A reporter (a system that watches something — its failed jobs, its alerts, its errors) names each problem by a key of its own and sends it whole, as it now is, each time it moves. A receiver (a help desk, a bug tracker, an on-call board) keeps one record per key. The wire is one `PUT` with an API key. The reporter marks a report owed inside the work that moved the problem and sends it later from its own schedule, retrying on a widening wait and never giving up; nothing is sent until the receiver's address and key are set. The receiver decides who may report and what a report becomes.

<!-- id: http-problem-reports-kit · area: http · stability: stable · html: https://osysharp.com/reference/http/problem-reports-kit/ -->

## Summary        {#summary}
Two kits connect an app that sees problems to an app that follows them. `Osysharp.ProblemReports` is the wire and the
receiving side: it reaches nobody and reads no secret. `Osysharp.ProblemReports.Sending` is the reporting side: it asks
for the receiver's host and the secret `ProblemReceiverKey`. Each app uses only the side it is, so a receiver grants no
egress. The reporter keeps nothing of the receiver in its source and the receiver nothing of the reporter: the address,
the key and the principal that reports are each environment's settings and data.

| you are | use | you set |
|---|---|---|
| the receiver (a desk, a tracker) | `use Osysharp.ProblemReports@0;` | `policy ReportsProblems`, `app.ProblemReports = new ProblemReportsSetup { Receiver = … }`, the route |
| the reporter (the system that sees problems) | `use Osysharp.ProblemReports@0;` and `use Osysharp.ProblemReports.Sending@0 { egress …; secret "ProblemReceiverKey"; }` | `policy SendsProblemReports`, `app.ProblemReporting = new ProblemReportingSetup { Source = …, ReceiverUrl = … }` |
| both | all of the above | both setups |

```osy syntax
// THE RECEIVER — use Osysharp.ProblemReports@0: who may report, and what a report becomes
policy ReportsProblems => RoleGrant.Any(g => g.Member == user && g.Role == DeskRole.Reporter);
app.ProblemReports = new ProblemReportsSetup { Receiver = new DeskTickets() };

// THE REPORTER — use Osysharp.ProblemReports.Sending@0: what a problem's report says, and where the receiver is
app.ProblemReporting = new ProblemReportingSetup { Source = new OvenProblems(), ReceiverUrl = Config.ProblemDeskUrl };
ProblemReportOwed(oven.Name);          // wherever a problem moves — one row, no call
SendOwedProblemReports();              // from the reporter's own schedule
```

One app may be both sides: it uses both kits and sets both setups.

## Signature      {#signature}
```osy syntax
use Osysharp.ProblemReports@0;                                                       // the wire; the receiving side
use Osysharp.ProblemReports.Sending@0 { egress "*"; secret "ProblemReceiverKey"; }   // the reporting side: the receiver's host, its key

// receiving side — Osysharp.ProblemReports
app.ProblemReports = new ProblemReportsSetup {
  Receiver    = new DeskTickets(),          // what a report becomes (IProblemReceiver); null takes no reports
};

// reporting side — Osysharp.ProblemReports.Sending
app.ProblemReporting = new ProblemReportingSetup {
  Source      = new OvenProblems(),         // what a problem's report says (IProblemSource)
  ReceiverUrl = Config.ProblemDeskUrl,      // the receiver's problems route — "" reports nowhere
  PerPass     = 20,                         // reports sent at most per pass
  FirstWait   = TimeSpan.FromMinutes(1),    // after the first failed try; doubling after it…
  LongestWait = TimeSpan.FromHours(1),      // …never longer than this
};

// receiving side — Osysharp.ProblemReports
virtual policy ReportsProblems => false;                          // who may report — nobody until the app says
public interface IProblemReceiver { ProblemReportReceipt Receive(ProblemReport report); }
[Authorize(ReportsProblems)] public ProblemReportReceipt ReceiveProblemReport(string key, ProblemReport report);
public bool ReceivesProblemReports();

// reporting side — Osysharp.ProblemReports.Sending
virtual policy SendsProblemReports => false;                      // who queues and sends
virtual policy ReadsProblemReports => SendsProblemReports;        // who reads the delivery log
public interface IProblemSource { ProblemReport? ReportOf(string key); }
[Authorize(SendsProblemReports)] public void ProblemReportOwed(string key);
[Authorize(SendsProblemReports)] public int SendOwedProblemReports();
public string ProblemReportLine(string key);
public bool ProblemReceiverConfigured();
public string ProblemReceiverAddress();
entity ProblemReportDelivery { Key, State, OwedSince, Attempts, NextAttemptAt, DeliveredAt, Delivered, Reference, Link, LastFailure, LastFailedAt }
```

## Description    {#description}

### What goes over the wire?   {#wire}
One request per report:

```text syntax
PUT <ReceiverUrl>/<key>
X-API-Key: <the receiver's key for this reporter>
Content-Type: application/json

{ "Report": { "Key": "oven-3", "Kind": "OvenTooHot", "Title": "Oven 3 runs hot", "Severity": "Warning", "Open": true,
              "Occurrences": 4, "Message": "282 °C against 250 set", "Link": "https://bakery.example/ovens/3",
              "Steps": [ { "Id": "oven-3-opened", "Kind": "Opened", "At": "2030-05-06T04:10:00Z", "Actor": "Platform" } ] } }
```

answered `200` with a `ProblemReportReceipt`:

```json syntax
{ "Reference": "#118", "Created": true, "Link": "https://desk.example/tickets/118" }
```

Both sides compile against the same classes, so neither keeps a copy that can drift. A reporter or receiver written in
another language sends or takes the same JSON.

| class | member | what it says |
|---|---|---|
| `ProblemReport` | `Key` | the reporter's own id for the problem, at most 100 characters; one key is one record on the receiver |
| | `Kind`, `Title`, `Severity`, `Stage`, `Open` | what kind, its headline (required), how bad (`"Critical"`, `"Warning"`, `"Info"`, or `""`), where it is in the reporter's flow, whether it is happening now |
| | `Zone`, `Application`, `Workflow`, `Signature` | where it happens, and what makes two occurrences the same problem — each null when it does not apply |
| | `FirstSeenAt`, `LastSeenAt`, `Occurrences` | how often, and when |
| | `Message`, `CorrelationId`, `ExceptionType`, `Frame`, `SourceLine`, `LogsLink` | the evidence of its latest occurrence; `Frame` is the reporter's own code, `SourceLine` the Osy# lines |
| | `Link` | where the reporter shows it — the link back |
| | `TriagedAt`, `Verdict`, `TriagedBy`, `TriagedByActor`, `TriageNote` | its triage, as the reporter's record says it |
| | `FixCommittedAt`, `FixCommit`, `FixedBy`, `FixNote`, `FixRelease`, `ReleasedAt` | its fix and the release it shipped in |
| | `VerifiedGoneAt`, `Regressions`, `RegressedAt`, `RegressedOnRelease` | whether it stayed gone |
| | `Steps` | every step the reporter recorded, oldest first |
| `ProblemReportStep` | `Id`, `Kind`, `At`, `Actor`, `By`, `Detail` | one step; a receiver keeps each once, by `Id` |
| `ProblemReportReceipt` | `Reference`, `Created`, `Link` | the receiver's own name for the record, whether this report made it, where it is shown |
| `ProblemReportBody` | `Report` | the request body |

A report is sent WHOLE each time, so the last report a receiver took is the problem as it is, and a report that
overtakes another still leaves the receiver right. A member added to these classes is a minor version of the kit — an
older receiver ignores it; one removed or renamed is a major one.

### Who may report to a receiver?   {#who-reports}
The receiver's `policy ReportsProblems` admits the callers. The usual caller is an integration principal — another
system, with an API key — that holds one role and nothing else. Which system that is, and its key, are the receiver's
DATA on its environment, made by its administrator: no source of either app names the other.

The receiver routes `ReceiveProblemReport` on its REST API ([publishing a REST API (app.Apis)](https://osysharp.com/reference/api/rest/)). It refuses a caller its policy does not
admit (403) and a call with no key (401). It refuses as a validation error a path key that is empty or over 100
characters, a body whose `Key` is not the path's, and a report with no `Title`. With no `Receiver` set it refuses every
report, saying the app names no receiver.

### What does a report become on the receiver?   {#receiver}
Whatever the app's `IProblemReceiver` makes of it — a ticket, an issue, a page — running as the caller, inside the
request, after the kit has checked it. Its promise: make the record the first time a key is reported, update it each
time after, keep each step once by its `Id`, and answer where it is. A reporter retries a report whose answer it lost,
so a report seen twice must change nothing twice.

### When is a report sent?   {#owed}
Never inside the work that moved the problem. `ProblemReportOwed(key)` is one row in that work's transaction, and it is
idempotent: a problem owed already stays owed once. `SendOwedProblemReports()`, called from the reporter's own schedule
([Schedule (recurring work)](https://osysharp.com/reference/scheduling/schedule/)), asks the `Source` for each owed problem's report as it is NOW and puts it — so a problem that
moved three times between two passes is sent once. It sends the oldest first, at most `PerPass`, and answers how many
the receiver took. A problem the source answers `null` for is gone, and is no longer owed.

### What happens when the receiver refuses or is down?   {#retries}
Nothing is given up on. A refusal (a 4xx) is kept on the delivery in the receiver's own words and tried again after
`FirstWait`, doubling each time up to `LongestWait`; the next owed report is tried in the same pass. No answer at all —
DNS, TLS, a timeout, a 5xx — ends the pass, and everything owed waits as long as that call: a receiver that is down
costs one call per wait, and the wait widens for the receiver, not per problem. The first refusal of a report is logged
as a warning once, and the report that goes through after failing is logged once. `SendOwedProblemReports` never throws
for a receiver's answer.

### Is it on before it is configured?   {#off-until-configured}
No. Until the reporter's `ReceiverUrl` has a value AND the secret `ProblemReceiverKey` has one, `SendOwedProblemReports`
sends nothing and answers 0, and `ProblemReceiverConfigured()` is false. What is owed is kept, and it goes with the
first pass after both are set. Give `ReceiverUrl` the value of an environment setting ([per-environment config (app.Config)](https://osysharp.com/reference/config/app-config/)), never a
literal: which receiver an environment reports to is deployment configuration, and an environment that should report
nowhere leaves it empty. The key is set per environment like any secret ([declaring secrets (app.Secrets)](https://osysharp.com/reference/config/secrets/)).

### What does the reporter show of a report?   {#line}
`ProblemReportLine(key)` is one line for the reporter's own page: `"Reported: #118."` once the receiver took it,
`"Owed: it goes with the next pass."`, the receiver's last refusal with how many tries, or that no receiver is
configured here. `ProblemReportDelivery` is the delivery log itself — one row per key, read under
`ReadsProblemReports`.

### Why may a reporter reach any public host?   {#egress}
The receiver's address is the reporter's environment setting, so `Osysharp.ProblemReports.Sending` declares
`egress consumer;` and the reporter grants it a host — the receiver's, or `egress "*";` for any public host
([Where your app may connect — the .egress file](https://osysharp.com/reference/project/egress/)). A receiver uses only `Osysharp.ProblemReports`, which declares no egress and no secret, so it grants
nothing: it makes no outbound call.

## Examples       {#examples}

### A bakery's ovens report to its maintenance desk   {#example-bakery}
One app is both sides here, so the whole wire runs inside it; in practice the two halves are two apps.

```osy title="the receiver's desk, the reporter's ovens, and the route between them" test app=http-problem-reports-kit
app Bakery {
  use Osysharp.Identity@0;
  use Osysharp.ProblemReports@0;
  use Osysharp.ProblemReports.Sending@0 { egress "*"; secret "ProblemReceiverKey"; }
  use Osysharp.Http;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.ProblemReports;
using Osysharp.ProblemReports.Sending;

[Role] enum BakeryRole { Authenticator, Baker, Mechanic, Reporter }

entity Person : Principal {
  security {
    allow read when IsAuthenticated || IsAuthenticator;
    allow create when IsAuthenticator;
  }
}

policy IsBaker => RoleGrant.Any(g => g.Member == user && g.Role == BakeryRole.Baker);
policy IsMechanic => RoleGrant.Any(g => g.Member == user && g.Role == BakeryRole.Mechanic);
/// The receiving side: only the reporting system, by its API key, may report.
policy ReportsProblems => RoleGrant.Any(g => g.Member == user && g.Role == BakeryRole.Reporter);
/// The reporting side: the bakers' schedule queues and sends.
policy SendsProblemReports => IsBaker;

// ── the receiver: the maintenance desk ──────────────────────────────────────────────────────────────────────────

/// One ticket per reported problem.
entity Ticket {
  [Required, Unique, MaxLength(100)] string ProblemKey;
  [Counter(TicketNumber)] int Number;
  [Required, MaxLength(300)] string Title;
  int Occurrences;
  bool Open;
  security {
    allow read when IsMechanic || ReportsProblems;
    allow create, update when ReportsProblems;
  }
}

Counter TicketNumber = new() { Start = 100 };

/// What a report becomes on the desk: a ticket the first time, the same ticket updated after.
public class DeskTickets : IProblemReceiver {
  public ProblemReportReceipt Receive(ProblemReport report) {
    var key = report.Key;
    var t = Ticket.FirstOrDefault(x => x.ProblemKey == key);
    var made = t == null;
    if (made) { t = new Ticket { ProblemKey = key, Title = report.Title }; }
    var ticket = t!;
    ticket.Title = report.Title;
    ticket.Occurrences = report.Occurrences;
    ticket.Open = report.Open;
    UnitOfWork.Commit();
    return new ProblemReportReceipt { Reference = "#" + ticket.Number, Created = made, Link = "https://desk.example/tickets/" + ticket.Number };
  }
}

// ── the reporter: the ovens ─────────────────────────────────────────────────────────────────────────────────────

entity Oven {
  [Required, Unique, MaxLength(100)] string Name;
  int Overheats;
  int LastReading;
  security { allow read, create, update when IsBaker; }
}

/// What an overheating oven's report says, as it is when it is sent.
public class OvenProblems : IProblemSource {
  public ProblemReport? ReportOf(string key) {
    var oven = Oven.FirstOrDefault(o => o.Name == key);
    if (oven == null || oven!.Overheats == 0) { return null; }
    return new ProblemReport { Key = key, Kind = "OvenTooHot", Title = key + " runs hot", Severity = "Warning", Open = true,
      Occurrences = oven!.Overheats, Message = oven!.LastReading + " °C", FirstSeenAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow };
  }
}

/// An oven read too hot: counted, and its report owed — nothing here calls the desk.
public void OvenTooHot(string name, int reading) {
  var oven = Oven.FirstOrDefault(o => o.Name == name) ?? new Oven { Name = name };
  oven.Overheats = oven.Overheats + 1;
  oven.LastReading = reading;
  ProblemReportOwed(name);
  UnitOfWork.Commit();
}

app.Secrets = [ new Secret("ProblemReceiverKey") ];
app.Config = [ new Setting("ProblemDeskUrl") { Default = "" } ];

app.ProblemReports = new ProblemReportsSetup { Receiver = new DeskTickets() };
app.ProblemReporting = new ProblemReportingSetup { Source = new OvenProblems(), ReceiverUrl = Config.ProblemDeskUrl };

app.Apis = [
  new RestApi("Problems") {
    Route = "problems",
    Auth = new ApiAuth { ApiKey = true },
    Endpoints = [ new Endpoint(ReceiveProblemReport) { Method = HttpMethod.Put, Path = "/{key}" } ],
  },
];
```

The reporter's schedule calls `SendOwedProblemReports()` every few minutes. The desk's environment gets an integration
principal holding `Reporter` and its API key; the bakery's environment sets `ProblemDeskUrl` to the desk's
`…/api/rest/v1/problems` and that key as `ProblemReceiverKey`.

## See also       {#see-also}
- [publishing a REST API (app.Apis)](https://osysharp.com/reference/api/rest/) — routing `ReceiveProblemReport`, and API-key callers
- [per-environment config (app.Config)](https://osysharp.com/reference/config/app-config/) — the receiver's address as an environment setting
- [declaring secrets (app.Secrets)](https://osysharp.com/reference/config/secrets/) — giving `ProblemReceiverKey` its value per environment
- [Where your app may connect — the .egress file](https://osysharp.com/reference/project/egress/) — granting the reporter the receiver's host
- [package.osy](https://osysharp.com/reference/project/package/) — `egress consumer;` and a kit's declared secret
- [a typed HTTP client (a class over Http.*)](https://osysharp.com/reference/http/client/) — the `Http.Put` under each report
- [Schedule (recurring work)](https://osysharp.com/reference/scheduling/schedule/) — the reporter's own pass
- [The Webhooks kit — telling other systems, signed, when something happens](https://osysharp.com/reference/ui/webhooks-kit/) — the other outbound shape: signed events to any subscriber
- [The Identity kit — who signs in, and which of the app's roles they hold](https://osysharp.com/reference/security/identity-kit/) — principals, roles and the integration that reports
