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 |
// 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 scheduleOne app may be both sides: it uses both kits and sets both setups.
Signature#
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#
What goes over the wire?#
One request per report:
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:
{ "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?#
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)). 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?#
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?#
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)), 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?#
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?#
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)), 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)).
What does the reporter show of a report?#
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?#
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). A receiver uses only Osysharp.ProblemReports, which declares no egress and no secret, so it grants
nothing: it makes no outbound call.
Examples#
A bakery's ovens report to its maintenance desk#
One app is both sides here, so the whole wire runs inside it; in practice the two halves are two apps.
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#
- publishing a REST API (app.Apis) — routing
ReceiveProblemReport, and API-key callers - per-environment config (app.Config) — the receiver's address as an environment setting
- declaring secrets (app.Secrets) — giving
ProblemReceiverKeyits value per environment - Where your app may connect — the .egress file — granting the reporter the receiver's host
- package.osy —
egress consumer;and a kit's declared secret - a typed HTTP client (a class over Http.*) — the
Http.Putunder each report - Schedule (recurring work) — the reporter's own pass
- The Webhooks kit — telling other systems, signed, when something happens — the other outbound shape: signed events to any subscriber
- The Identity kit — who signs in, and which of the app's roles they hold — principals, roles and the integration that reports