Osy#betaRun it on Osyrin: osyrin.com

Reference / HTTP

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

use Osysharp.ProblemReports@0; · use Osysharp.ProblemReports.Sending@0; · ProblemReport · ProblemReportStep · ProblemReportReceipt · ProblemReportBody · app.ProblemReports = new ProblemReportsSetup { Receiver } · app.ProblemReporting = new ProblemReportingSetup { Source, ReceiverUrl, PerPass, FirstWait, LongestWait } · policy ReportsProblems · IProblemReceiver.Receive(report) · ReceiveProblemReport(key, report) · policy SendsProblemReports · policy ReadsProblemReports · IProblemSource.ReportOf(key) · ProblemReportOwed(key) · SendOwedProblemReports() · ProblemReportLine(key) · ProblemReceiverConfigured() · ProblemReceiverAddress() · ReceivesProblemReports() · ProblemReportDelivery · secret ProblemReceiverKey

`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.

stable1 example compiled by CIhttpkitintegrationoperations

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 areuseyou 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 = … }
bothall of the aboveboth 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 schedule

One 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.

classmemberwhat it says
ProblemReportKeythe reporter's own id for the problem, at most 100 characters; one key is one record on the receiver
Kind, Title, Severity, Stage, Openwhat 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, Signaturewhere it happens, and what makes two occurrences the same problem — each null when it does not apply
FirstSeenAt, LastSeenAt, Occurrenceshow often, and when
Message, CorrelationId, ExceptionType, Frame, SourceLine, LogsLinkthe evidence of its latest occurrence; Frame is the reporter's own code, SourceLine the Osy# lines
Linkwhere the reporter shows it — the link back
TriagedAt, Verdict, TriagedBy, TriagedByActor, TriageNoteits triage, as the reporter's record says it
FixCommittedAt, FixCommit, FixedBy, FixNote, FixRelease, ReleasedAtits fix and the release it shipped in
VerifiedGoneAt, Regressions, RegressedAt, RegressedOnReleasewhether it stayed gone
Stepsevery step the reporter recorded, oldest first
ProblemReportStepId, Kind, At, Actor, By, Detailone step; a receiver keeps each once, by Id
ProblemReportReceiptReference, Created, Linkthe receiver's own name for the record, whether this report made it, where it is shown
ProblemReportBodyReportthe 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#

Related

publishing a REST API (app.Apis)

`app.Apis` publishes your app to a third party over HTTP. You never write a controller or an endpoint handler — you…

per-environment config (app.Config)

`app.Config` declares your app's per-environment settings — values that differ between development and production, like…

declaring secrets (app.Secrets)

`app.Secrets` declares the named secrets your app uses — API keys, tokens, client secrets. Each is `new…

package.osy

The manifest that makes a git repository a publishable Osy# package. It names the package, says which of the…

Where your app may connect — the .egress file

An application's own outbound allow-list and deny-list, kept in the project's .egress file. The osy egress verbs edit…

a typed HTTP client (a class over Http.*)

A typed HTTP client is an **ordinary class** that wraps an external API: a `BaseUrl` field, a credential field, one…

Schedule (recurring work)

A schedule produces work on a cadence. Each occurrence creates a row of the entity its Template names — carrying…

The Webhooks kit — telling other systems, signed, when something happens

`Osysharp.Webhooks` sends other systems a signed JSON document each time an event happens to a record — any public…

The Identity kit — who signs in, and which of the app's roles they hold

`Osysharp.Identity` is the contract every people-aware kit builds on: `Principal`, the one `[Principal]` of the app (an…