Driving Sentry Init from your own code
Everything the web page does is one HTTP API. The base URL is
https://api.skillsafe.ai/v1/app-api, every call carries
Authorization: Bearer <token>, and every response is the same envelope. The app
is identified by the token, not by a path segment — the app slug never appears in a request
path, and a route built by inserting it returns 404.
The task field comes first
Sentry Init is one app with four lanes over the same work object: the init call your
project already ships. Every request must carry a task, and the
reply is that lane's contract and no other. A request with no task is answered by
the closest lane, with the chosen lane named in lane and explained in the first
sentence of summary — useful as a fallback, not as a way of asking.
| task | lane | verdict values | artifact_json.kind | what you get |
|---|---|---|---|---|
audit | Configuration audit | production-ready | minor-gaps | incomplete | misconfigured | config_audit | the option-by-option verdict per init block, plus the corrected init call |
privacy | Privacy review | contained | needs-scrubbing | exposed | privacy_plan | the data categories that can reach Sentry, and a complete beforeSend scrubber |
budget | Sampling plan | within-quota | tight | over-quota | sampling_plan | the sample rates that fit the quota, and what each change costs in visibility |
signals | Signal coverage | covered | partial | blind | signal_plan | signal-by-signal coverage, the alert rules worth creating, and OTel exporter wiring |
The input fields
Taken from readForm() in app.js, which is what the page actually sends.
Only task and config are required; everything else improves the answer
and several fields make a whole section of it possible.
| field | type | what it is |
|---|---|---|
task | string, required | One of audit, privacy, budget, signals. Routes the run to a lane. |
config | string, required | The init call itself. Several files in one string is normal - separate them with a // file: path marker line. The page masks credential-shaped values before this field is built; if you are calling the API directly, mask them yourself. |
platforms | string | Comma-joined platform labels the browser detected, e.g. Next.js, Python. Advisory - the model re-reads the config. |
environment | string | production, staging, mixed or unknown. |
strictness | string | balanced, strict or pragmatic. |
masking_on | boolean | Whether the placeholders in config replaced real values. |
volumes | object | errors, pageViews, sessions, requests, spansPerTransaction - all numbers, all per month. Zero means not supplied. |
quotas | object | errors, spans, replays, profiles - the plan allowance. Zero means not supplied. |
budget | object | The arithmetic the browser already did: rows[] with the monthly figure, the allowance, whether it is over and the rate that would fit, plus assumptions[]. The prompt treats this as authoritative and does not recompute it. |
prescan | object | The parse: blocks[] (platform, file, syntax, options, DSN structure) and flags[], each with a unique flag_id, its rule, a severity and the block it belongs to. |
notes | string | Free text the config cannot carry - the plan you are on, why a rate was changed. |
upstream | string | The digest of a previous lane's result, when one lane hands over to the next. |
The traffic fields are load-bearing for one lane. With volumes left
empty, budget.computed is false and the budget lane will say the
arithmetic could not be done rather than inventing an average. That is deliberate.
The envelope
Every response is {"ok":true,"data":{...}} or
{"ok":false,"error":{"code":"...","message":"..."}}, with a
meta.request_id on both. Job output arrives as a string inside
data.output.output — parse that string as JSON to get the app's reply object.
| status | error.code | what it means |
|---|---|---|
| 401 | UNAUTHORIZED | No token, or a token that is not for this app. Mint one with POST /guest. |
| 402 | INSUFFICIENT_CREDITS | The balance is under min_credits. /estimate is free and tells you the number in advance. |
| 404 | NOT_FOUND | Usually a path with the app slug in it. The slug never appears in a request path - the token identifies the app. |
| 409 | CONFLICT | An Idempotency-Key reused with a different body. |
| 422 | VALIDATION_ERROR | The input object was not accepted - most often task missing or not one of the four lane ids. |
| 429 | RATE_LIMITED | Back off and retry. Never tight-loop. |
| 503 | UNAVAILABLE | The model tier is briefly unavailable. Retry with the same Idempotency-Key. |
The client used below
# A token first (see step 1). Everything below reuses it.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from tokens.html, or POST /guest below
def call(method, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html, or POST /guest below
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body)
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ? json.error.code + ": " + json.error.message : res.status);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from tokens.html, or POST /guest below
func call(method, path string, body any) (map[string]any, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data map[string]any `json:"data"`
Error *struct{ Code, Message string } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
class SentryInitClient {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from tokens.html, or POST /guest below
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String jsonBody) throws Exception {
HttpRequest.BodyPublisher body = jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, body)
.build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN" # from tokens.html, or POST /guest below
def call(method, path, body = nil)
uri = URI(BASE.to_s + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
JSON.parse(res.body).fetch("data")
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html, or POST /guest below
function call(string $method, string $path, ?array $body = null): array {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json"],
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$raw = curl_exec($ch);
curl_close($ch);
$env = json_decode($raw, true);
if (isset($env["error"])) throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
return $env["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
class SentryInitClient {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from tokens.html, or POST /guest below
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path, object body = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data");
}
}
Step 1 — get a token
A guest token is enough for /me and /estimate. Running a lane is
metered, so it needs a personal token — the easy way to get one is
the token page, which signs in and shows you the token this browser
holds. Reuse one token: every POST /guest mints a new identity, and
run history is scoped to the identity that wrote it.
curl -s -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"sentry-init"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest", ...}}
guest = call("POST", "/guest", {"slug": "sentry-init"})
TOKEN = guest["token"] # reuse this exact token for every later call
const guest = await call("POST", "/guest", { slug: "sentry-init" });
// reuse guest.token for every later call - a second POST /guest is a NEW identity
guest, err := call("POST", "/guest", map[string]any{"slug": "sentry-init"})
if err != nil {
panic(err)
}
fmt.Println(guest["token"])
String guest = SentryInitClient.call("POST", "/guest", "{\"slug\":\"sentry-init\"}");
System.out.println(guest);
guest = call("POST", "/guest", { "slug" => "sentry-init" })
puts guest["token"]
$guest = call("POST", "/guest", ["slug" => "sentry-init"]);
echo $guest["token"];
var guest = await Call(HttpMethod.Post, "/guest", new { slug = "sentry-init" });
Console.WriteLine(guest.GetProperty("token").GetString());
Step 2 — check the token with /me
subject_type is user or guest; credits is the
balance a metered run draws on. Compare it against min_credits from the estimate
before you submit, rather than discovering a 402 afterwards.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := call("GET", "/me", nil)
fmt.Println(me["subject_type"], me["credits"])
System.out.println(SentryInitClient.call("GET", "/me", null));
me = call("GET", "/me")
puts me["subject_type"], me["credits"]
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"];
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Step 3 — price the run with /estimate
/estimate is free and creates no job. It returns the model binding
as well as the price, which makes it the cheapest way to assert an app is wired to the model you
think it is: model reads gpt-5.6-terra, model_alias reads
gpt-terra, and markup_bps is 1000. Estimate the exact
payload you are about to run — the hold differs per lane.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":1627,"min_credits":198,
# "sponsor_enabled":false}}
est = call("POST", "/estimate", run_input) # run_input is the object above
print(est["hold_credits"], est["min_credits"], est["model"])
# estimate is FREE and creates no job. hold_credits is a RESERVATION, not a price:
# you are charged for what the run actually uses, which is usually far less.
const est = await call("POST", "/estimate", runInput);
console.log(est.hold_credits, est.min_credits, est.model_alias);
est, _ := call("POST", "/estimate", runInput)
fmt.Println(est["hold_credits"], est["model_alias"])
String est = SentryInitClient.call("POST", "/estimate", runInputJson);
System.out.println(est);
est = call("POST", "/estimate", run_input)
puts est["hold_credits"], est["model_alias"]
$est = call("POST", "/estimate", $runInput);
echo $est["hold_credits"], " ", $est["model_alias"];
var est = await Call(HttpMethod.Post, "/estimate", runInput);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
The request body
Trimmed for the page; prescan.blocks and budget.rows are full objects.
{
"task": "audit",
"config": "// file: instrumentation-client.ts\nimport * as Sentry from \"@sentry/nextjs\";\n\nSentry.init({\n dsn: \"https://[DSN-KEY-1]@o4508912.ingest.us.sentry.io/4508913\",\n environment: \"production\",\n tracesSampleRate: 1.0,\n});",
"platforms": "Next.js",
"environment": "production",
"strictness": "balanced",
"masking_on": true,
"volumes": {
"errors": 90000,
"pageViews": 1800000,
"sessions": 350000,
"requests": 4200000,
"spansPerTransaction": 10
},
"quotas": {
"errors": 50000,
"spans": 10000000,
"replays": 50000,
"profiles": 100000
},
"budget": {
"computed": true,
"verdict": "over-quota",
"rows": [
"... see below ..."
]
},
"prescan": {
"readable": true,
"platforms": [
"Next.js"
],
"blocks": [
"... one entry per init block ..."
],
"flags": [
{
"flag_id": "traces-full-b1-tracessamplerate",
"rule": "traces-full",
"severity": "high",
"block": 1,
"option": "tracesSampleRate",
"title": "tracesSampleRate is 1.0",
"detail": "Every transaction is sent."
}
]
},
"notes": "",
"upstream": ""
}
Step 4 — run a lane and poll the job
POST /run returns a job_id immediately; poll
GET /jobs/{job_id} until status is succeeded or
failed. Always send an Idempotency-Key derived from
the task, the input and an attempt counter — a retried network blip must not bill twice,
and the key must include the task because two lanes over one config are two distinct runs.
# The idempotency key must be stable for one (task, input, attempt).
KEY="sentry-init:audit:$(shasum input.json | cut -c1-16):1"
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d @input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# poll until terminal
until curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| tee /dev/stderr | grep -q '"status":"\(succeeded\|failed\)"'; do sleep 2; done
import hashlib, time
key = "sentry-init:%s:%s:1" % (run_input["task"],
hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:16])
job = call("POST", "/run", run_input) # add Idempotency-Key: key on the request
while True:
j = call("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the app's JSON envelope
print(reply["verdict"], len(reply["findings"]))
const key = `sentry-init:${runInput.task}:${hash(runInput)}:1`;
const job = await call("POST", "/run", runInput, { "Idempotency-Key": key });
let j;
do {
await new Promise(r => setTimeout(r, 2000));
j = await call("GET", `/jobs/${job.job_id}`);
} while (j.status !== "succeeded" && j.status !== "failed");
const reply = JSON.parse(j.output.output);
console.log(reply.verdict, reply.findings.length);
job, _ := call("POST", "/run", runInput) // set Idempotency-Key on the request
id := job["job_id"].(string)
for {
j, _ := call("GET", "/jobs/"+id, nil)
if j["status"] == "succeeded" || j["status"] == "failed" {
fmt.Println(j["output"])
break
}
time.Sleep(2 * time.Second)
}
String job = SentryInitClient.call("POST", "/run", runInputJson);
// then poll GET /jobs/{job_id} until status is succeeded or failed
job = call("POST", "/run", run_input) # set the Idempotency-Key header
loop do
j = call("GET", "/jobs/#{job['job_id']}")
break puts(j["output"]) if %w[succeeded failed].include?(j["status"])
sleep 2
end
$job = call("POST", "/run", $runInput); // set the Idempotency-Key header
do {
sleep(2);
$j = call("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"], true));
echo $j["output"]["output"];
var job = await Call(HttpMethod.Post, "/run", runInput);
JsonElement j;
do {
await Task.Delay(2000);
j = await Call(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));
Step 5 — stream it instead
POST /run-stream is the same call over SSE. Three event types arrive:
job once at the start, delta repeatedly, and done at the
end with charged_credits and truncated.
Each delta carries the whole text so far, not an increment —
assign it, do not append it.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d @input.json
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"audit\",\"title\":\"..."}
# event: done {"output":"...","charged_credits":412,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(run_input).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf = ""
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().strip()
if line.startswith("data:"):
payload = json.loads(line[5:].strip())
if "text" in payload:
buf = payload["text"] # the delta carries the WHOLE text so far
print(json.loads(buf)["verdict"])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json",
"Idempotency-Key": key },
body: JSON.stringify(runInput)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = "", full = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
const data = frame.split("\n").filter(l => l.startsWith("data:"))
.map(l => l.slice(5).trim()).join("");
if (!data) continue;
const payload = JSON.parse(data);
if (payload.text) full = payload.text; // cumulative, not incremental
}
}
console.log(JSON.parse(full).verdict);
// POST /run-stream and read the body line by line; frames are separated by a
// blank line and each data: payload is JSON. payload.text is CUMULATIVE.
// POST /run-stream with HttpResponse.BodyHandlers.ofLines() and accumulate the
// data: frames; each payload.text is the whole text so far, not a delta.
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
# frames are separated by a blank line; parse the data: lines as JSON
print chunk
end
end
end
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) {
// frames are separated by a blank line; each data: line is JSON
echo $chunk;
return strlen($chunk);
});
using var stream = await Http.GetStreamAsync(Base + "/run-stream");
using var reader = new StreamReader(stream);
string line;
while ((line = await reader.ReadLineAsync()) != null) {
if (line.StartsWith("data:")) { /* JSON payload; payload.text is cumulative */ }
}
The output contract
One JSON object, the same envelope in every lane. The page parses it with
JSON.parse after stripping an optional code fence, so prose around the object is a
defect rather than a decoration.
{
"lane": "audit" | "privacy" | "budget" | "signals",
"title": "short name for this run",
"verdict": "one of the lane's values",
"headline": "one sentence under 160 characters",
"summary": "two to four sentences",
"checks": [ { "name", "value", "verdict": "good|weak|missing|risky|not-applicable", "note" } ],
"findings": [ { "id", "severity": "critical|high|medium|low", "option", "block": 1,
"quote", "why", "fix" } ],
"rates": [ { "signal": "errors|spans|replays|profiles|logs|crons",
"current": "1.0", "recommended": "0.1", "events_per_month": 0, "note" } ],
"artifact": "the lane's document, in Markdown",
"artifact_json": { "kind": "config_audit|privacy_plan|sampling_plan|signal_plan", ... },
"coverage_check": [ { "flag_id", "status": "confirmed|cleared|not-applicable", "note" } ],
"questions": [ "..." ],
"confidence": "high|medium|low"
}
coverage_check is the accountability mechanism. Every flag the
browser raised carries a unique flag_id, and the reply must answer each one exactly
once. The page shows any flag left unanswered and any id that was invented, so a reply that
ignores the prescan is visible rather than plausible.
The same rule fires once per init block, so ids are suffixed with the block:
before-send-missing-b1 and before-send-missing-b2 are two separate
answers. Read prescan.flags[].rule when you want to group them.
Worked example: task: "audit"
Required in this lane: title, verdict, headline, summary, checks, findings, artifact, artifact_json, coverage_check, confidence. Verdict is one of production-ready | minor-gaps | incomplete | misconfigured, and artifact_json.kind must be config_audit.
Reply (trimmed)
{
"lane": "audit",
"title": "Next.js Sentry Init \u2014 Audit (browser + server)",
"verdict": "minor-gaps",
"headline": "Errors, tracing and replay all reach Sentry, but no release on either side and a 100% browser trace rate need fixing before this scales.",
"summary": "This audits the two Next.js Sentry.init calls you pasted \u2014 instrumentation-client.ts (browser) and sentry.server.config.ts (server) \u2014 both pointed at the same project, 4508913. The essentials are present: a valid DSN, environment set to production, tracing enabled on both sides, and Session Replay on in the browser. The one gap present on both blocks that actually costs a named feature is release (no source maps, no release health); a missing beforeSend and unstated replay-masking options are real, but for this lane \u2014 is the init correct and complete \u2014 they are notes rather than correctness gaps, since the SDK already behaves safely by default. The browser's tracesSampleRate of 1.0 is the one setting worth changing this week.",
"checks": [
{
"name": "dsn (browser, instrumentation-client.ts)",
"value": "https://[DSN-KEY-1]@o4508912.ingest.us.sentry.io/4508913",
"verdict": "good",
"note": "Identifies the project events are sent to; DSN parses as valid with no legacy secret."
},
{
"name": "environment (browser)",
"value": "production",
"verdict": "good",
"note": "Separates production events from other stages in issue lists, alerts and release health."
},
"... 18 more ..."
],
"findings": [
{
"id": "traces-full-b1-tracessamplerate",
"severity": "high",
"option": "tracesSampleRate",
"block": 1,
"quote": "tracesSampleRate: 1.0",
"why": "Every browser transaction is sent at 100%. That is fine for a first day in production and expensive for a month of real traffic \u2014 it is the single most common way a Sentry span bill grows without anything actually breaking.",
"fix": "tracesSampleRate: 0.2, // was 1.0 (100%); matches the server rate below and cuts browser span volume roughly 5x \u2014 refine the exact number against your quota in the budget lane"
},
"... 11 more ..."
],
"rates": [],
"artifact": "# SENTRY-AUDIT.md\n\n## Block 1 \u2014 `instrumentation-client.ts` (Next.js, browser)\n\n**Verdict: minor-gaps**\n\n| # | Option | Set to | Verdict | What turns on it |\n|---|--------|--------|---------|-------------------|\n| 1 | dsn | `https://[DSN-KEY-1]@o4508912.ingest.us.sentry.io/4508913` | good | Sends ev\n... (full Markdown document) ...",
"artifact_json": {
"kind": "config_audit",
"score": 72,
"blocks": [
{
"index": 1,
"platform": "Next.js",
"file": "instrumentation-client.ts",
"verdict": "minor-gaps",
"missing": [
"release",
"beforeSend",
"tracePropagationTargets",
"tunnel",
"denyUrls",
"ignoreErrors"
]
},
{
"index": 2,
"platform": "Next.js",
"file": "sentry.server.config.ts",
"verdict": "minor-gaps",
"missing": [
"release",
"beforeSend"
]
}
]
},
"coverage_check": [
{
"flag_id": "traces-full-b1-tracessamplerate",
"status": "confirmed",
"note": "Agreed with the prescan's high severity \u2014 captures every browser transaction; addressed with a lower recommended rate in findings."
},
{
"flag_id": "release-missing-b1-release",
"status": "confirmed",
"note": "Agreed \u2014 no release key in the browser init; kept at the prescan's medium, since release health has no release to measure without it."
},
"... 10 more, one per prescan flag_id ..."
],
"questions": [
"Does the installed @sentry/nextjs version auto-register a profiling integration when profilesSampleRate is set on the server, or does @sentry/profiling-node's nodeProfilingIntegration() need to be added to sentry.server.config.ts by hand? The pasted server block sets profilesSampleRate: 1.0 with no integrations array at all, and I can't tell from the pasted text alone which behavior applies.",
"What is the @sentry/nextjs package version? Option names and defaults for tracePropagationTargets and browserTracingIntegration have changed across major versions."
],
"confidence": "medium"
}
Worked example: task: "privacy"
Required in this lane: title, verdict, headline, summary, checks, findings, artifact, artifact_json, coverage_check, confidence. Verdict is one of contained | needs-scrubbing | exposed, and artifact_json.kind must be privacy_plan.
Reply (trimmed)
{
"lane": "privacy",
"title": "Sentry init ships PII, unmasked replay and a legacy DSN secret",
"verdict": "exposed",
"headline": "sendDefaultPii, unmasked Session Replay and a legacy-secret DSN are sending real user data to Sentry right now -- fix those three before anything else.",
"summary": "This is the privacy lane: what this pasted init sends about your users, not whether it is correctly configured. Both the browser block (src/monitoring.js) and the Node block (server/instrument.js) ship sendDefaultPii: true with no beforeSend, so IP addresses, cookies and the Authorization header are attached to every event unfiltered today. The browser block also records every session with Session Replay's masking switched off (maskAllText, maskAllInputs and blockAllMedia are all false), the server block ships every stack frame's local variables, and the browser DSN still carries a legacy key:secret pair. Given this is a healthcare product, verdict is exposed.",
"checks": [
{
"name": "Request data \u2014 IP address, cookies & Authorization header",
"value": "sendDefaultPii: true in both blocks; no beforeSend in either",
"verdict": "risky",
"note": "Set sendDefaultPii: false, or keep it true and strip request.headers.authorization/.cookie and user.ip_address in beforeSend."
},
{
"name": "Session replay DOM \u2014 on-screen text, form inputs, media",
"value": "replayIntegration({ maskAllText: false, maskAllInputs: false, blockAllMedia: false }), replaysSessionSampleRate: 1.0",
"verdict": "risky",
"note": "Remove the three false overrides so the SDK's masking defaults (all true) apply, or replace them with the sentry-mask / sentry-unmask CSS-class allowlist."
},
"... 3 more ..."
],
"findings": [
{
"id": "dsn-legacy-secret-b1-dsn",
"severity": "critical",
"option": "dsn",
"block": 1,
"quote": "dsn: \"https://[DSN-KEY-1]:[DSN-SECRET-1]@o4501234.ingest.us.sentry.io/4501235\"",
"why": "The DSN carries a key:secret@ pair. The secret half has not been required since 2016; anyone who has this string can write events into project 4501235 as if they were you. That is a live credential shipped in a browser bundle, which is exactly the kind of thing legal asked about when they asked what Sentry can see.",
"fix": "Rotate the client key in Sentry (Settings > Client Keys), then ship only the key half: dsn: \"https://[DSN-KEY-1]@o4501234.ingest.us.sentry.io/4501235\", -- replace [DSN-KEY-1] with the newly issued key."
},
"... 11 more ..."
],
"rates": [],
"artifact": "# SENTRY-PRIVACY.md\n\n## What this init sends about your users today\n\nTwo init blocks were pasted: a browser bundle (`src/monitoring.js`, `@sentry/browser`) and a\nserver process (`server/instrument.js`, `@sentry/node`). Read together, this configuration is\n**exposed**: personal data is leaving Sentry\n... (full Markdown document) ...",
"artifact_json": {
"kind": "privacy_plan",
"exposed_categories": [
"request-headers",
"replay-dom",
"local-variables",
"breadcrumb-urls",
"trace-headers",
"dsn-credential"
],
"controls": [
{
"option": "sendDefaultPii",
"set_to": "false",
"block": 1,
"why": "Stops the browser SDK from attaching the visitor's IP address to every event by default."
},
{
"option": "sendDefaultPii",
"set_to": "false",
"block": 2,
"why": "Stops the Node SDK from attaching the request's IP address, cookies and Authorization header to every event by default."
},
{
"option": "maskAllText",
"set_to": "true",
"block": 1,
"why": "Restores DOM text masking in Session Replay recordings."
},
{
"option": "maskAllInputs",
"set_to": "true",
"block": 1,
"why": "Restores form-input masking in Session Replay recordings."
},
{
"option": "blockAllMedia",
"set_to": "true",
"block": 1,
"why": "Stops images and video elements from being captured into Session Replay recordings."
},
{
"option": "includeLocalVariables",
"set_to": "false",
"block": 2,
"why": "Stops server stack frames from shipping local variable values, where request bodies and records tend to end up."
},
{
"option": "beforeSend",
"set_to": "scrubber shown in the artifact",
"block": 1,
"why": "Gives the browser SDK a last chance to strip anything sendDefaultPii or a breadcrumb still carries before the event leaves the page."
},
{
"option": "beforeSend",
"set_to": "scrubber shown in the artifact",
"block": 2,
"why": "Gives the Node SDK a last chance to strip request headers and cookies before the event leaves the process."
},
{
"option": "tracePropagationTargets",
"set_to": "[/^https:\\/\\/api\\.your-app\\.com/]",
"block": 1,
"why": "Stops trace headers from attaching to third-party requests the wildcard currently matches."
},
{
"option": "dsn",
"set_to": "rotate the key, drop the secret half",
"block": 1,
"why": "The current DSN is a live credential; the secret half lets anything holding it write to the project."
}
]
},
"coverage_check": [
{
"flag_id": "dsn-legacy-secret-b1-dsn",
"status": "confirmed",
"note": "A key:secret@ credential is present in the pasted DSN; treated as an active exposure, see findings."
},
{
"flag_id": "replay-unmasked-b1-maskalltext",
"status": "confirmed",
"note": "maskAllText: false is set in the replay integration and is captured as-is above."
},
"... 22 more, one per prescan flag_id ..."
],
"questions": [
"Does the Sentry organization or project have server-side data-scrubbing rules configured that already redact any of this before storage?",
"Is there a reverse proxy or API gateway in front of server/instrument.js that already strips Authorization/Cookie headers before the request reaches this process?"
],
"confidence": "high"
}
Worked example: task: "budget"
Required in this lane: title, verdict, headline, summary, checks, rates, artifact, artifact_json, coverage_check, confidence. Verdict is one of within-quota | tight | over-quota, and artifact_json.kind must be sampling_plan.
Reply (trimmed)
{
"lane": "budget",
"title": "Sentry budget check \u2014 Python + Go, over quota on errors and spans",
"verdict": "over-quota",
"headline": "Spans run 9x over quota and errors 5.2x over \u2014 cut TracesSampleRate/SampleRate from 1.0 before the next bill.",
"summary": "Both blocks run tracesSampleRate/traces_sample_rate at 1.0 with no tracesSampler to override it, and the Go worker also holds its error SampleRate at 1.0 -- against the quotas you typed, that's spans at 9x over (90,000,000 vs 10,000,000/mo) and errors at 5.2x over (260,000 vs 50,000/mo). Replays sit unused at 0 since neither block ships a browser SDK, and profiles run 7,500,000/mo against a quota that reads as 0, which looks like an unpurchased add-on rather than a genuine allowance. Nothing about the app needed to change for the bill to triple -- these rates were already at 1.0; a smaller included quota on the new Team plan is the more likely cause.",
"checks": [
{
"name": "Spans per transaction",
"value": "12",
"verdict": "weak",
"note": "Typed by the team, not measured from a live trace -- Django ORM N+1 patterns or added Go worker spans could push this well above 12, which would lower every 'rate that fits' figure further."
},
{
"name": "Sessions that saw an error",
"value": "0",
"verdict": "good",
"note": "Both blocks run server-only Python and Go processes with no browser SDK (runs_in_browser: false) -- there are no end-user sessions to replay, so 0 is exact, not an estimate, and the 500-replay quota is simply unused rather than at risk."
},
"... 0 more ..."
],
"findings": [
{
"id": "traces-full-b1-tracessamplerate",
"severity": "high",
"option": "traces_sample_rate",
"block": 1,
"quote": "traces_sample_rate=1.0",
"why": "This is the Python side of a flat 1.0 trace rate that puts spans at 9x the 10,000,000/mo quota (90,000,000 vs 10,000,000).",
"fix": "traces_sampler=traces_sampler, # was traces_sample_rate=1.0 -- replace the flat rate with a sampler that zeroes health checks and bases the rest at 0.08"
},
"... 3 more ..."
],
"rates": [
{
"signal": "errors",
"current": "1.0",
"recommended": "0.15",
"events_per_month": 260000,
"note": "SampleRate is 1.0 in the Go worker (Python leaves sample_rate unset, same 1.0 default) against a 50,000/mo quota -- 260,000 events is 5.2x over. Dropping to 0.15 projects to about 39,000/mo (78% of quota, some headroom), but for a 3.47% error rate on 7,500,000 requests the more durable fix is filtering the noisy/duplicate error at the source rather than sampling it away blind."
},
"... 3 more ..."
],
"artifact": "# SENTRY-SAMPLING.md\n\n## Where the bill is going\n\nBoth init blocks trace at a flat `1.0` with no `tracesSampler` to override it (`sampler_present: false`),\nand the Go worker also holds its error `SampleRate` at `1.0`. Against the quotas you typed:\n\n| Signal | Current rate | Monthly at that rate | \n... (full Markdown document) ...",
"artifact_json": {
"kind": "sampling_plan",
"recommended": {
"sampleRate": 0.15,
"tracesSampleRate": 0.08,
"replaysSessionSampleRate": 0,
"profilesSampleRate": 0
},
"estimated_monthly": {
"errors": 39000,
"spans": 7200000,
"replays": 0,
"profiles": 0
}
},
"coverage_check": [
{
"flag_id": "traces-full-b1-tracessamplerate",
"status": "confirmed",
"note": "Confirmed -- this is the block-1 (Python) tracesSampleRate driving the 90,000,000/mo span volume; addressed in rates and the artifact plan."
},
{
"flag_id": "traces-full-b2-tracessamplerate",
"status": "confirmed",
"note": "Confirmed -- the same setting in block 2 (Go); both blocks feed the same quota and need to move together."
},
"... 4 more, one per prescan flag_id ..."
],
"questions": [
"Is Continuous Profiling actually included on the current plan, or is the 0 profiles quota literal?",
"Does this version of sentry-go expose a TracesSampler function option, the way the Python SDK does, so the worker can get the same health-check exclusion as the Python side instead of a flat rate?"
],
"confidence": "medium"
}
Worked example: task: "signals"
Required in this lane: title, verdict, headline, summary, checks, findings, artifact, artifact_json, coverage_check, confidence. Verdict is one of covered | partial | blind, and artifact_json.kind must be signal_plan.
Reply (trimmed)
{
"lane": "signals",
"title": "Next.js Sentry Signals Review: Core Monitoring On, Key Surfaces Missing",
"verdict": "partial",
"headline": "Errors and performance both reach Sentry today, but no release means no release health, and logs and user feedback aren't wired up either.",
"summary": "This is a signals-lane review of two Next.js Sentry init blocks - instrumentation-client.ts (browser) and sentry.server.config.ts (server) - that share one project (4508913). Errors and performance/tracing are both reachable end to end, and Session Replay is active in the browser, but no release is set in either block, so release health, regression marking and automatic source maps are unavailable. Logs and user feedback have no corresponding option or integration anywhere in this paste, and tracePropagationTargets is unset on both sides, so a new external host would silently be included or excluded from the trace by default rather than by a stated list. Cron/check-ins and AI/LLM monitoring can't be judged from these two bootstrap files either way, since application code that would call a model provider or run a scheduled job would live elsewhere.",
"checks": [
{
"name": "Errors",
"value": "dsn set in both instrumentation-client.ts and sentry.server.config.ts; default error capture is on in both.",
"verdict": "good",
"note": "Reachable today. A tunnel would stop the share of browser errors that ad blockers drop before they reach ingest.sentry.io - see the tunnel-missing-b1 finding."
},
{
"name": "Performance/tracing",
"value": "tracesSampleRate: 1.0 in instrumentation-client.ts, tracesSampleRate: 0.2 in sentry.server.config.ts, browserTracingIntegration() enabled.",
"verdict": "good",
"note": "Whether 1.0/0.2 are the right rates is a budget-lane question, not a signals one - the signal itself is reachable on both sides of the request."
},
"... 8 more ..."
],
"findings": [
{
"id": "release-missing-b1",
"severity": "medium",
"option": "release",
"block": 1,
"quote": "",
"why": "instrumentation-client.ts has no release option. Without it, this block's errors can't be tied to a deploy, source maps won't apply, and release health has nothing to measure.",
"fix": "Sentry.init({\n dsn: \"https://[DSN-KEY-1]@o4508912.ingest.us.sentry.io/4508913\",\n release: env.NEXT_PUBLIC_SENTRY_RELEASE, // set by the Sentry Next.js build plugin, or your own CI commit SHA\n // ...\n});"
},
"... 5 more ..."
],
"rates": [],
"artifact": "## Coverage today\n\nTwo Next.js Sentry init blocks share one project (`4508913`): `instrumentation-client.ts` runs in\nthe browser, `sentry.server.config.ts` runs on the server. Between them:\n\n| Signal | What you have today | Verdict |\n| --- | --- | --- |\n| Errors | dsn set in both blocks; default err\n... (full Markdown document) ...",
"artifact_json": {
"kind": "signal_plan",
"coverage": {
"errors": true,
"performance": true,
"release_health": false,
"replay": true,
"logs": false,
"crons": "unknown",
"ai": "unknown"
},
"alerts": [
{
"name": "Production error volume spike",
"condition": "Number of events > 100 in 1 hour, filtered to level:error, project-wide",
"environment": "production",
"notify": "Whatever Slack/email/PagerDuty integration is connected under Settings -> Integrations for this project (connect one first if none exists)."
},
{
"name": "New issue first seen in production",
"condition": "A new issue is created",
"environment": "production",
"notify": "On-call/triage channel for this project."
},
{
"name": "Performance regression on a key transaction",
"condition": "p95(transaction.duration) increases by more than 20% week over week",
"environment": "production",
"notify": "Backend on-call channel."
},
{
"name": "Crash-free session rate drop",
"condition": "Crash-free sessions % drops below 99% over 1 hour (requires release to be set - see the release-missing findings)",
"environment": "production",
"notify": "Release owner / on-call channel."
},
{
"name": "Error-triggered replay volume spike",
"condition": "Count of replays with an error exceeds a threshold in 1 hour (replaysOnErrorSampleRate is 1.0, so this tracks 1:1 with client error volume)",
"environment": "production",
"notify": "Frontend on-call channel."
}
]
},
"coverage_check": [
{
"flag_id": "traces-full-b1-tracessamplerate",
"status": "not-applicable",
"note": "A sampling-rate/cost question for the budget lane; the performance signal is reachable regardless of what the rate is."
},
{
"flag_id": "release-missing-b1-release",
"status": "confirmed",
"note": "This is exactly why Release health is marked missing below."
},
"... 10 more, one per prescan flag_id ..."
],
"questions": [
"Does this app call an LLM or AI provider (e.g. via the Vercel AI SDK)? Neither init file would show that even if true, and it decides whether the AI/LLM monitoring row is worth acting on.",
"Does this app run any scheduled jobs (cron routes, queue workers)? Same visibility limit as above."
],
"confidence": "medium"
}
Chaining the lanes
The page's handoff buttons are just a second call: take the result of lane A, build a short
digest of it, and send that as upstream on lane B with the same
config. The order the app follows is
audit → privacy → budget →
signals, and the digest keeps the later lane from repeating what the earlier one
established.
const audit = await runLane({ ...base, task: "audit" });
const privacy = await runLane({ ...base, task: "privacy", upstream: digest(audit) });
const budget = await runLane({ ...base, task: "budget", upstream: digest(privacy) });
const signals = await runLane({ ...base, task: "signals", upstream: digest(budget) });
Rate limits and cost
/estimate,/meand/guestare free. Only/runand/run-streamare billed.hold_creditsis a reservation against the full output cap. The charge ischarged_creditson thedoneevent, and it is usually far lower.- If the balance sits between
min_creditsandhold_credits, the run still executes with a reduced output cap and comes back with"truncated": true. Treat that as a partial answer, not a complete one - the page renders whatever sections parsed and says how many. - Data endpoints are 120 requests/minute; vector similarity over the run history is 30/minute per IP.
Reading the app's own contract
/llms.txt states what this app takes, what the free browser-side prescan computes, the four lanes and the output envelope, in a form an answer engine or an agent can read without executing anything.