Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
0c34756
fix: add API Reference sidebar category label for docs
fclairamb Aug 31, 2026
018dcbf
fix: copy root CHANGELOG.md into the docs-builder container stage
fclairamb Aug 31, 2026
6ab943c
chore: all checks passing for docs API Reference label and container …
fclairamb Aug 31, 2026
5b7d7e5
docs: archive completed spec docs API Reference label and container c…
fclairamb Aug 31, 2026
3d27dbd
fix: read SP_FILESTORAGE_LOCAL_ROOT and document file storage in .env…
fclairamb Aug 31, 2026
5f807de
Merge remote-tracking branch 'origin/main' into batch/2026-08-31
fclairamb Aug 31, 2026
f8e19e3
docs: add File Storage configuration page
fclairamb Aug 31, 2026
742d973
docs: add File Storage to the configuration quick reference
fclairamb Aug 31, 2026
ff0cc5f
docs: document persistent file storage for the Kubernetes install guide
fclairamb Aug 31, 2026
fd21110
docs: cover file storage volumes in the Docker install guide
fclairamb Aug 31, 2026
b689296
docs: add solidping-files volume to the Docker Compose install guide
fclairamb Aug 31, 2026
fdac0a3
docs: cross-link file storage from the Linux and Windows install guides
fclairamb Aug 31, 2026
f76319c
docs: cover file storage in the homepage quick start
fclairamb Aug 31, 2026
c1c67fe
chore: all checks passing for docs file storage and install volume gap
fclairamb Aug 31, 2026
c19ebfb
docs: archive completed spec docs file storage and install volume gap
fclairamb Aug 31, 2026
e62e84c
chore: start implementing hold child confirmation while hard parent v…
fclairamb Aug 31, 2026
6c00b47
feat(incidents): hold a child's confirmation while a hard parent is s…
fclairamb Aug 31, 2026
664f65f
feat(checkdependencies): soft confirmation-margin warning on hard dep…
fclairamb Aug 31, 2026
64638ce
test(incidents): confirmation-hold suite on SQLite and Postgres
fclairamb Aug 31, 2026
73cf101
test(checkdependencies): confirmation-margin lint boundary and timeou…
fclairamb Aug 31, 2026
5f465b7
test(models): Check.TimeoutOrDefault fallback table
fclairamb Aug 31, 2026
8c77dd1
feat(dash0): amber confirmation-margin warning on the dependencies view
fclairamb Aug 31, 2026
cb0faf5
docs(wiki): document the confirmation hold and the soft margin lint
fclairamb Aug 31, 2026
77b5f02
fix(incidents): guard a nil ancestor row in the confirmation-hold walk
fclairamb Aug 31, 2026
d744211
chore: satisfy golangci-lint in the new confirmation-hold test files
fclairamb Aug 31, 2026
862d291
chore: all checks passing for hold child confirmation while hard pare…
fclairamb Aug 31, 2026
80e9a8f
docs: archive completed spec hold child confirmation while hard paren…
fclairamb Aug 31, 2026
13f3474
fix(incidents): keep rollup attribution on detach and emit its event
fclairamb Sep 1, 2026
c94cc86
feat(dash0): locale labels for incident.rollup_detached
fclairamb Sep 1, 2026
331503f
docs(wiki): fix check-dependencies.md to match rollup detach behavior
fclairamb Sep 1, 2026
01ed418
test(incidents): cover rollup detach's attribution and paging contract
fclairamb Sep 1, 2026
cb3b12b
fix(system): route incident.rollup_detached through the activation-fu…
fclairamb Sep 1, 2026
6f41587
chore: all checks passing for rollup detach erases attribution
fclairamb Sep 1, 2026
dd55f74
docs: archive completed spec rollup detach erases attribution
fclairamb Sep 1, 2026
0b0c1ff
fix: route every ingest path through the configured check timeout
fclairamb Sep 1, 2026
d0d49ca
chore: all checks passing for ingest paths ignore configured check ti…
fclairamb Sep 1, 2026
f904494
docs: archive completed spec ingest paths ignore configured check tim…
fclairamb Sep 1, 2026
136e157
chore: start implementing check stats cache stale empty org hero
fclairamb Sep 1, 2026
11de7d5
docs: add implementation plan for check stats cache stale empty org hero
fclairamb Sep 1, 2026
422d01f
test(checks): prove check create/delete/clone bust the stats cache
fclairamb Sep 1, 2026
e3626f0
fix(dash0): invalidate check-stats on check create/delete
fclairamb Sep 1, 2026
299d57e
fix(dash0): refresh check-stats on live checks-kind hints
fclairamb Sep 1, 2026
40cdedd
test(e2e): prove the server-side stats cache bust in the onboarding flow
fclairamb Sep 1, 2026
8ce50f4
chore: all checks passing for check stats cache stale empty org hero
fclairamb Sep 1, 2026
34fa306
docs: archive completed spec check stats cache stale empty org hero
fclairamb Sep 1, 2026
7c9f617
Merge remote-tracking branch 'origin/main' into batch/2026-08-31
fclairamb Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,44 @@ SP_TELEGRAM_BASE_URL=
# or a broken dispatch loop.
SP_ENTITLEMENTS_TELEGRAM_RUNAWAY_PER_HOUR=

# ---------------------------------------------------------------------------
# File storage (org logos, status-page assets, incident screenshots)
# ---------------------------------------------------------------------------
# Two backends: "local" (default) and "s3". Blobs are addressed by the scheme
# recorded at write time, so switching backends is backward compatible —
# existing file:// blobs keep resolving through the local backend after you
# move new writes to S3.
#
# WARNING for containers: "local" writes to SP_FILESTORAGE_LOCAL_ROOT
# (default ./data/files). If that path is not a mounted volume, every upload
# lives on the container's ephemeral layer and is destroyed by the next
# restart — silently, with no error until something reads the blob back. Either
# mount a volume there or use the S3 backend.
SP_FILESTORAGE_TYPE=local
SP_FILESTORAGE_LOCAL_ROOT=./data/files

# --- S3 backend (SP_FILESTORAGE_TYPE=s3) -------------------------------------
# Bucket is required. Region is required by the SDK even for stores that ignore
# it. Prefix is optional and lets several deployments share one bucket.
SP_FILESTORAGE_S3_BUCKET=
SP_FILESTORAGE_S3_REGION=
SP_FILESTORAGE_S3_PREFIX=

# Leave BOTH of these empty for AWS S3: the standard AWS credential chain
# (env, IAM role, shared config) is used. Set them to pin static credentials,
# which is what any S3-compatible store needs. SECRET — never logged.
SP_FILESTORAGE_S3_ACCESS_KEY=
SP_FILESTORAGE_S3_SECRET_KEY=

# Custom endpoint. Empty = AWS S3. Set it for MinIO, Garage, Ceph/RGW, R2,
# Backblaze B2, OVH, etc. Path-style addressing is required by MinIO/Garage/Ceph
# and optional elsewhere; it never needs per-bucket DNS, so it is the safe
# choice when unsure.
# MinIO : SP_FILESTORAGE_S3_ENDPOINT=http://localhost:9000 + USE_PATH_STYLE=true
# OVH : SP_FILESTORAGE_S3_ENDPOINT=https://s3.gra.io.cloud.ovh.net (region gra)
SP_FILESTORAGE_S3_ENDPOINT=
SP_FILESTORAGE_S3_USE_PATH_STYLE=

# ---------------------------------------------------------------------------
# Frontend dev server (web/dash0)
# ---------------------------------------------------------------------------
Expand Down
42 changes: 42 additions & 0 deletions server/internal/app/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10136,6 +10136,48 @@ components:
type: array
items:
$ref: "#/components/schemas/Dependency"
warnings:
type: array
description: >-
Soft configuration lint over this check's hard `dependsOn`
edges. Advisory only — never a validation error, and never a
reason a write is rejected. Always present (possibly empty).
items:
$ref: "#/components/schemas/DependencyWarning"

DependencyWarning:
type: object
description: >-
One soft lint finding about a dependency edge.


`CONFIRMATION_MARGIN_TOO_SHORT` is raised for a hard `dependsOn` edge
where
`child.confirmation < parent.confirmation + parent.period + parent.timeout`:
the child can finish confirming before its parent could possibly have
observed the same outage even once. The runtime confirmation hold
already covers the gap at page time (the child stays `validating` while
a hard parent is still validating), so this only explains why a page
may arrive later than the child's configured confirmation suggests.
properties:
code:
type: string
enum: [CONFIRMATION_MARGIN_TOO_SHORT]
dependencyUid:
type: string
parentCheck:
$ref: "#/components/schemas/CheckRef"
childConfirmationSeconds:
type: integer
description: The child check's configured confirmation period, in seconds.
recommendedConfirmationSeconds:
type: integer
description: >-
`parent.confirmation + parent.period + parent.timeoutOrDefault`,
rounded up to whole seconds.
message:
type: string
description: English fallback text; localized clients key off `code` instead.

CreateDependencyRequest:
type: object
Expand Down
18 changes: 15 additions & 3 deletions server/internal/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,7 @@ func (s *Server) SetupRoutes(ctx context.Context) {
orgSeverities.DELETE("/:uid", severitiesHandler.DeleteSeverity)

// Check dependency routes (authentication required)
depsService := checkdependencies.NewService(s.dbService)
depsService := checkdependencies.NewService(s.dbService, s.defaultCheckTimeout())
depsHandler := checkdependencies.NewHandler(depsService, s.config)
orgChecks.GET("/:check/dependencies", depsHandler.ListForCheck)
orgChecks.POST("/:check/dependencies", depsHandler.Create)
Expand Down Expand Up @@ -1117,7 +1117,8 @@ func (s *Server) SetupRoutes(ctx context.Context) {

// Heartbeat ingestion routes (public, token-based auth)
heartbeatService := heartbeat.NewService(
s.dbService, s.jobSvc, s.services.Realtime, incidentPublicationsService)
s.dbService, s.jobSvc, s.services.Realtime, incidentPublicationsService,
s.defaultCheckTimeout())
heartbeatHandler := heartbeat.NewHandler(heartbeatService, s.config)
publicOrgAPI.POST("/heartbeat/:org/:identifier", heartbeatHandler.ReceiveHeartbeat)
publicOrgAPI.GET("/heartbeat/:org/:identifier", heartbeatHandler.ReceiveHeartbeat)
Expand Down Expand Up @@ -1166,6 +1167,7 @@ func (s *Server) SetupRoutes(ctx context.Context) {
agentWorkerIncidents := incidents.NewService(
s.dbService, s.jobSvc, s.services.Clock, s.services.Realtime)
agentWorkerIncidents.SetPublicationHook(incidentPublicationsService)
agentWorkerIncidents.SetDefaultCheckTimeout(s.defaultCheckTimeout())

agentWorkersSvc := workers.NewService(
s.dbService,
Expand Down Expand Up @@ -1239,6 +1241,7 @@ func (s *Server) SetupRoutes(ctx context.Context) {
// Incidents routes (authentication required)
incidentsService := incidents.NewService(s.dbService, s.jobSvc, s.services.Clock, s.services.Realtime)
incidentsService.SetPublicationHook(incidentPublicationsService)
incidentsService.SetDefaultCheckTimeout(s.defaultCheckTimeout())
incidentsService.SetAttachmentStore(attachmentsService)
incidentsHandler := incidents.NewHandler(incidentsService, s.config)
orgIncidents := orgGroup("/orgs/:org/incidents")
Expand Down Expand Up @@ -1466,7 +1469,8 @@ func (s *Server) SetupRoutes(ctx context.Context) {
// cancellable context.
s.jmapManager = jmap.NewManager(s.dbService)
s.jmapManager.RegisterHandler(emailcheck.NewHandler(
s.dbService, s.jobSvc, s.services.Realtime, incidentPublicationsService, slog.Default()))
s.dbService, s.jobSvc, s.services.Realtime, incidentPublicationsService, slog.Default(),
s.defaultCheckTimeout()))
systemService.SetEmailInboxManager(s.jmapManager)

systemHandler := system.NewHandler(systemService, s.config)
Expand Down Expand Up @@ -3693,6 +3697,14 @@ func (s *Server) JobSvc() jobsvc.Service {
return s.jobSvc
}

// defaultCheckTimeout is the resolved global per-check execution ceiling
// (`scheduling.check_timeout_ms`) as a duration. It feeds the incident
// service's confirmation-hold cap and the dependency lint's margin formula,
// both of which need "how late can a check possibly notice an outage".
func (s *Server) defaultCheckTimeout() time.Duration {
return s.config.Server.Scheduling.CheckTimeout()
}

// Sentinel errors surfaced by newFreeboxConnectionResolver. Defining
// them statically lets callers branch on them and keeps err113 happy
// without spelling out every formatted variant.
Expand Down
1 change: 1 addition & 0 deletions server/internal/checkworker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ func NewCheckWorker(
checkJobSvc checkjobsvc.Service,
) *CheckWorker {
incidentSvc := incidents.NewService(dbService, svc.Jobs, clock.Real{}, svc.Realtime)
incidentSvc.SetDefaultCheckTimeout(cfg.Server.Scheduling.CheckTimeout())

// The in-process worker is the path most incidents actually open on, so it
// needs the status-page publication hook too — without it, auto-publish
Expand Down
37 changes: 32 additions & 5 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,22 @@ type SchedulingConfig struct {
FastLaneReserved int `koanf:"fast_lane_reserved"`
}

// CheckTimeout returns CheckTimeoutMs as a duration.
//
// Every consumer of the global per-check execution ceiling must go through
// this method rather than repeating the ms→Duration conversion: the ceiling
// feeds both check execution and the incident confirmation-hold cap
// (incidents.Service.ancestorHoldRemaining), and a call site that quietly
// skips it gets a different hold cap than the rest of the process for the
// same deployment.
//
// A non-positive CheckTimeoutMs is returned as 0 — the documented "falls back
// to the built-in default" behavior belongs to each consumer (see
// incidents.DefaultCheckTimeoutFallback), not to the raw conversion.
func (c SchedulingConfig) CheckTimeout() time.Duration {
return time.Duration(c.CheckTimeoutMs * float64(time.Millisecond))
}

// RedirectRule represents a path-based redirect configuration for development proxying.
type RedirectRule struct {
PathPrefix string // e.g., "/dashboard"
Expand Down Expand Up @@ -2391,12 +2407,23 @@ func applyRuntimeEnv(cfg *RuntimeConfig) {
}
}

// applyFileStorageEnv reads SP_FILESTORAGE_S3_* into cfg. koanf's env loader
// collapses every underscore in SP_*-prefixed names to a dot, so it would map
// these to filestorage.s3.bucket / filestorage.s3.endpoint etc. and miss the
// snake_case koanf tags ("s3_bucket", "s3_endpoint", ...). Reading them here
// makes the whole S3 backend env-configurable for containerized self-hosters.
// applyFileStorageEnv reads SP_FILESTORAGE_LOCAL_ROOT and SP_FILESTORAGE_S3_*
// into cfg. koanf's env loader collapses every underscore in SP_*-prefixed
// names to a dot, so it would map these to filestorage.local.root /
// filestorage.s3.bucket etc. and miss the snake_case koanf tags ("local_root",
// "s3_bucket", ...). Reading them here makes both backends env-configurable for
// containerized self-hosters.
//
// SP_FILESTORAGE_TYPE is deliberately absent: it is a single-word key, so
// koanf's collapse maps it straight onto the "type" tag already.
func applyFileStorageEnv(cfg *FileStorageConfig) {
// The local root matters most in containers: left at its default it points
// at ./data/files inside the image, so unless that path is a mounted
// volume every upload dies with the container — silently, since nothing
// fails until a later read.
if v := os.Getenv("SP_FILESTORAGE_LOCAL_ROOT"); v != "" {
cfg.LocalRoot = v
}
if v := os.Getenv("SP_FILESTORAGE_S3_BUCKET"); v != "" {
cfg.S3Bucket = v
}
Expand Down
38 changes: 38 additions & 0 deletions server/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ func TestParseLogLevel(t *testing.T) {
func TestApplyFileStorageEnv(t *testing.T) {
r := require.New(t)

t.Setenv("SP_FILESTORAGE_LOCAL_ROOT", "/var/lib/solidping/files")
t.Setenv("SP_FILESTORAGE_S3_BUCKET", "solidping")
t.Setenv("SP_FILESTORAGE_S3_REGION", "eu-west-3")
t.Setenv("SP_FILESTORAGE_S3_PREFIX", "blobs")
Expand All @@ -317,6 +318,12 @@ func TestApplyFileStorageEnv(t *testing.T) {
var cfg FileStorageConfig
applyFileStorageEnv(&cfg)

// Regression: SP_FILESTORAGE_LOCAL_ROOT used to be dropped entirely — koanf
// maps it to filestorage.local.root, which misses the "local_root" tag, and
// the manual reader did not cover it. A container told to store blobs on a
// mounted volume silently kept writing to ./data/files inside the image and
// lost every upload on restart.
r.Equal("/var/lib/solidping/files", cfg.LocalRoot)
r.Equal("solidping", cfg.S3Bucket)
r.Equal("eu-west-3", cfg.S3Region)
r.Equal("blobs", cfg.S3Prefix)
Expand Down Expand Up @@ -1669,3 +1676,34 @@ func TestApplyServerEnv_CORSAllowedOrigins(t *testing.T) {
applyServerEnv(cfg)
r.Equal([]string{"https://c.example.com"}, cfg.CORSAllowedOrigins, "SP_SERVER_ prefix wins over the shorter name")
}

// TestSchedulingCheckTimeout pins the single ms→Duration conversion every
// consumer of the per-check execution ceiling must go through. It exists
// because the conversion used to be copy-pasted at each call site, and the
// call sites that forgot to copy it (heartbeat, email-check and MCP ingest)
// silently ran the incident confirmation-hold gate on a different ceiling
// than the worker did.
func TestSchedulingCheckTimeout(t *testing.T) {
t.Parallel()

for name, tc := range map[string]struct {
ms float64
want time.Duration
}{
"shipped default": {ms: 15000, want: 15 * time.Second},
"raised": {ms: 45000, want: 45 * time.Second},
"lowered": {ms: 2500, want: 2500 * time.Millisecond},
"fractional ms": {ms: 1.5, want: 1500 * time.Microsecond},
// 0 stays 0: applying the documented built-in default is each
// consumer's job (incidents.DefaultCheckTimeoutFallback), not the
// raw conversion's.
"unset": {ms: 0, want: 0},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()

cfg := SchedulingConfig{CheckTimeoutMs: tc.ms}
require.Equal(t, tc.want, cfg.CheckTimeout())
})
}
}
1 change: 1 addition & 0 deletions server/internal/config/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ func manualReaderPlatformEnvVars() []string {
"SP_RUNTIME_MEMORY_LIMIT_RATIO",
"SP_RUNTIME_GC_PERCENT",
// applyFileStorageEnv
"SP_FILESTORAGE_LOCAL_ROOT",
"SP_FILESTORAGE_S3_BUCKET",
"SP_FILESTORAGE_S3_REGION",
"SP_FILESTORAGE_S3_PREFIX",
Expand Down
29 changes: 29 additions & 0 deletions server/internal/db/models/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,35 @@ type Check struct {
TargetHostSortKey string `bun:"target_host_sort_key,scanonly"`
}

// CheckConfigKeyTimeout is the check-config key holding the optional
// per-check execution timeout, stored as a Go duration string
// (spec 2026-07-11-05).
const CheckConfigKeyTimeout = "timeout"

// TimeoutOrDefault resolves the check's per-execution timeout: the explicit
// `timeout` entry in its config when it parses to a positive duration, and
// `defaultTimeout` (the server's scheduling.check_timeout_ms) otherwise.
//
// This is a READ-side approximation of what the worker actually applies —
// the worker additionally clamps an unset timeout by the cost EWMA and caps
// an explicit one (checkworker.perCheckTimeout). Both consumers here want an
// upper bound on "how late can this check possibly notice an outage", and the
// unclamped default is exactly that bound: the cost-aware clamp only ever
// shortens it.
func (c *Check) TimeoutOrDefault(defaultTimeout time.Duration) time.Duration {
raw, ok := c.Config[CheckConfigKeyTimeout].(string)
if !ok || raw == "" {
return defaultTimeout
}

parsed, err := time.ParseDuration(raw)
if err != nil || parsed <= 0 {
return defaultTimeout
}

return parsed
}

// RegionSpreadDuration returns the check's optional inter-region spread
// override as a *time.Duration (nil when unset), for the
// scheduling.RegionSpread resolver. Keeps the *timeutils.Duration ⇄
Expand Down
45 changes: 45 additions & 0 deletions server/internal/db/models/check_timeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package models_test

import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/fclairamb/solidping/server/internal/db/models"
)

// Check.TimeoutOrDefault is read by both consumers of the confirmation-margin
// formula (the runtime hold cap and the dependency lint), so a wrong fallback
// silently mis-sizes both. Anything that is not a positive, parseable duration
// must fall back to the server default rather than to zero.
func TestCheckTimeoutOrDefault(t *testing.T) {
t.Parallel()

const fallback = 15 * time.Second

cases := []struct {
name string
config models.JSONMap
want time.Duration
}{
{name: "nil config", config: nil, want: fallback},
{name: "empty config", config: models.JSONMap{}, want: fallback},
{name: "explicit timeout", config: models.JSONMap{"timeout": "30s"}, want: 30 * time.Second},
{name: "sub-second timeout", config: models.JSONMap{"timeout": "250ms"}, want: 250 * time.Millisecond},
{name: "empty string", config: models.JSONMap{"timeout": ""}, want: fallback},
{name: "unparseable", config: models.JSONMap{"timeout": "soon"}, want: fallback},
{name: "zero", config: models.JSONMap{"timeout": "0s"}, want: fallback},
{name: "negative", config: models.JSONMap{"timeout": "-5s"}, want: fallback},
{name: "wrong type", config: models.JSONMap{"timeout": 30}, want: fallback},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

check := &models.Check{Config: tc.config}
require.Equal(t, tc.want, check.TimeoutOrDefault(fallback))
})
}
}
9 changes: 9 additions & 0 deletions server/internal/db/models/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ const (
// incident-open time: this one changes a live incident's paging behavior
// mid-flight, so the timeline has to say so. Never pages.
EventTypeIncidentRolledUp EventType = "incident.rolled_up"
// EventTypeIncidentRollupDetached indicates a suppressed child was
// un-attached from its rollup parent because the parent resolved AND the
// child's own check had already recovered (spec
// 2026-08-31-07-rollup-detach-erases-attribution). paging_suppressed
// flips false, but caused_by_incident_uid is deliberately kept — it is
// the post-mortem record of which cascade this incident belonged to.
// Never pages: the child never paged on the way in, so it does not page
// on the way out either.
EventTypeIncidentRollupDetached EventType = "incident.rollup_detached"
// EventTypeIncidentAcknowledged indicates an incident was acknowledged.
EventTypeIncidentAcknowledged EventType = "incident.acknowledged"
// EventTypeIncidentUnacknowledged indicates an acknowledgment was cleared.
Expand Down
Loading
Loading