Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
5d5d8c1
docs: plan API monitoring (M16): request rollups, every API stack, da…
productdevbook Oct 14, 2026
634d9d2
feat(api): API properties count their requests per route, with error …
productdevbook Oct 14, 2026
4fcac89
feat(sdk/go): analytics Middleware records a net/http API's requests …
productdevbook Oct 14, 2026
a2f178e
chore(sdk/js): regenerate schema.gen.ts for API properties and reques…
productdevbook Oct 14, 2026
afc19d6
test(api): API monitoring load test at 300M requests a month
productdevbook Oct 14, 2026
3318cf0
docs: API monitoring architecture follows what M16.1 shipped
productdevbook Oct 14, 2026
b99fb76
docs: API monitoring guide and settings in en, tr and de
productdevbook Oct 14, 2026
7c3d553
feat(web): API properties with an overview, a routes table and a page…
productdevbook Oct 14, 2026
7a55201
feat(api): hide API routes, tell owners about the request records lim…
productdevbook Oct 14, 2026
a516793
feat(sdk/go): regenerate the API client for API routes
productdevbook Oct 14, 2026
a8f8a4e
chore(sdk/js): regenerate schema.gen.ts for API routes, the request l…
productdevbook Oct 14, 2026
eef4932
docs: API monitoring architecture records route hiding, the limit not…
productdevbook Oct 14, 2026
4036262
docs: API monitoring guide covers hiding routes, the request limit no…
productdevbook Oct 14, 2026
ed8ff7d
docs: (other) is in an API property's routes list from its first route
productdevbook Oct 14, 2026
52bf061
feat(web): API property settings and hiding routes
productdevbook Oct 14, 2026
21c4237
fix(web): hidden route pages show no figures, latency axes fit, route…
productdevbook Oct 14, 2026
7d18031
docs: API monitoring minute and hour labels carry their UTC offset
productdevbook Oct 14, 2026
b6e7657
fix(api): API time series fill every bucket once and label minutes an…
productdevbook Oct 14, 2026
409d23f
chore(sdk): regenerate the Go client and schema.gen.ts for offset tim…
productdevbook Oct 14, 2026
dcef90c
fix(web): API charts read the offset in minute and hour labels
productdevbook Oct 14, 2026
d5dfe01
feat(site): Cloudflare e-mail page and a works-with strip on the home…
productdevbook Oct 14, 2026
a0e9e80
fix(site): one works-with strip on the home page
productdevbook Oct 14, 2026
6ad4c71
feat(site): Nitro's mark in the works-with strip
productdevbook Oct 14, 2026
e0f4534
fix(site): real h3 mark, MinIO and Firebase marks, phone code blocks …
productdevbook Oct 14, 2026
af834d4
feat(sdk/js): createMonitor and API monitoring middleware for srvx, h…
productdevbook Oct 14, 2026
4fe0d68
fix(site): Cloudflare e-mail code blocks start at their first character
productdevbook Oct 14, 2026
439de17
feat(site): srvx, Hono, Express and Fastify in the works-with strip
productdevbook Oct 14, 2026
124f6a1
feat(api): API monitoring takes OpenTelemetry traces and app-side req…
productdevbook Oct 14, 2026
196b4ea
chore(sdk): regenerate the Go client and schema.gen.ts for OTLP trace…
productdevbook Oct 14, 2026
78861d7
chore(web): regenerate schema.gen.ts for OTLP traces and app-side req…
productdevbook Oct 14, 2026
d975ccb
docs: API monitoring architecture records how OTLP and app-side inges…
productdevbook Oct 14, 2026
6b83b41
fix(sdk/js): API monitoring flushes at once on serverless platforms a…
productdevbook Oct 14, 2026
786eced
fix(api): OTLP spans of the HTTP semantic conventions 1.20 and before…
productdevbook Oct 14, 2026
6439ada
chore(sdk): regenerate the Go client and schema.gen.ts for the older …
productdevbook Oct 14, 2026
77e7acf
chore(web): regenerate schema.gen.ts for the older OTLP attribute names
productdevbook Oct 14, 2026
5cac4dd
docs: API monitoring guides for Go, JavaScript frameworks and OpenTel…
productdevbook Oct 14, 2026
181890b
feat(sdk/swift): YuvaAnalytics times the app's calls to its own API
productdevbook Oct 14, 2026
fbbbba9
feat(sdk/kotlin): yuva-analytics times the app's calls to its own API
productdevbook Oct 14, 2026
1f0393d
feat(api): full API analysis: every dimension, consumers, comparisons…
productdevbook Oct 14, 2026
ddc91e5
docs: API monitoring from iOS and Android apps
productdevbook Oct 14, 2026
4f75e6f
chore(sdk): regenerate the Go client and schema.gen.ts for the full A…
productdevbook Oct 14, 2026
76a5c96
docs: API monitoring architecture records how M16.3 analysis shipped
productdevbook Oct 14, 2026
2f35d31
feat(api): latency heatmap: requests per duration band from the durat…
productdevbook Oct 14, 2026
59393f3
chore(sdk): regenerate the Go client and schema.gen.ts for latency bands
productdevbook Oct 14, 2026
ea728bd
feat(web): API filters, breakdowns, consumers, comparisons, Apdex and…
productdevbook Oct 14, 2026
d6f1d42
feat(web): latency heatmap on an API route's page
productdevbook Oct 14, 2026
42e33ef
fix(web): API route page no longer blanks, and new and silent routes …
productdevbook Oct 14, 2026
1c152fe
fix(web): Turkish label for using the property's Apdex threshold
productdevbook Oct 14, 2026
aed9a27
docs: how to read API monitoring numbers, percentiles and the analysi…
productdevbook Oct 14, 2026
8c6d1eb
feat(api): API alerts, uptime checks, API reports and monitoring MCP …
productdevbook Oct 14, 2026
5bd4f28
chore(sdk): regenerate the Go client and schema.gen.ts for uptime che…
productdevbook Oct 14, 2026
2da160f
feat(api): new and silent routes carry their first and last request t…
productdevbook Oct 14, 2026
f761447
chore(sdk): regenerate the Go client and schema.gen.ts for route requ…
productdevbook Oct 14, 2026
ac53144
docs: API monitoring architecture records how M16.4 checks and alerts…
productdevbook Oct 14, 2026
645a758
docs: uptime checks, alerts, reports and monitoring MCP tools, with t…
productdevbook Oct 14, 2026
a626e11
feat(web): uptime checks, API alert rules and alerts, API reports
productdevbook Oct 14, 2026
8c7633f
chore(deploy): the development compose lets uptime checks reach local…
productdevbook Oct 14, 2026
db4c3cc
feat(api): share links, exports and consumer erasure for API monitoring
productdevbook Oct 14, 2026
ca9e355
chore(sdk): regenerate the Go client and schema.gen.ts for API monito…
productdevbook Oct 14, 2026
cdf8af3
fix(api): a query with an empty metrics list answers 400 instead of 500
productdevbook Oct 14, 2026
38472c0
style(api): gofmt analytics_exports.go
productdevbook Oct 14, 2026
80a6bf2
docs: API monitoring architecture records how M16.5 sharing, export a…
productdevbook Oct 14, 2026
17332d4
docs: share, export, retention and consumer erasure for API monitorin…
productdevbook Oct 14, 2026
3c1387b
fix(web): monitoring texts in the panel's language, and check pages o…
productdevbook Oct 14, 2026
5a2db23
fix(api): an uptime check recovers while most of the server's checks …
productdevbook Oct 14, 2026
7a1a6e8
chore(sdk): regenerate the Go client and schema.gen.ts for the uptime…
productdevbook Oct 14, 2026
0746bf0
feat(web): share links, exports, retention and consumer erasure for A…
productdevbook Oct 14, 2026
07e7e55
fix(web): Consumers opens on the last 7 days, and the response-time u…
productdevbook Oct 14, 2026
39e8c7d
feat(api): service health, dependencies and incidents, in progress (M…
productdevbook Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
15 changes: 14 additions & 1 deletion api/cmd/yuva/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const usageText = `usage:
yuva workspace broadcasts limit --workspace <id|name> --daily <n> (0 removes the limit)
yuva workspace broadcasts show --workspace <id|name>
yuva workspace analytics-limit --workspace <id|name> <events|0> (0 removes the limit)
yuva workspace monitor-limit --workspace <id|name> <requests|0> (0 removes the limit)
yuva analytics property create --workspace <id|name> --name <name> --domain <host>... [--inbox <id|slug>] [--platform web|ios|android]... [--timezone <zone>]
(prints the property id and its public key, tab-separated)
yuva analytics property list --workspace <id|name>
Expand Down Expand Up @@ -266,6 +267,9 @@ func serve(ctx context.Context, cfg config.Config, log *slog.Logger, args []stri
if cfg.WebhookAllowPrivate {
log.Warn("YUVA_WEBHOOK_ALLOW_PRIVATE is set; webhooks may reach private and loopback addresses")
}
if cfg.Analytics.Monitor.AllowPrivate {
log.Warn("YUVA_MONITOR_ALLOW_PRIVATE is set; uptime checks may call private and loopback addresses")
}
if cfg.MigrationAllowPrivate {
log.Warn("YUVA_MIGRATION_ALLOW_PRIVATE is set; migrations may connect to mail servers on private and loopback addresses")
}
Expand All @@ -274,6 +278,8 @@ func serve(ctx context.Context, cfg config.Config, log *slog.Logger, args []stri
}
if !cfg.Analytics.On {
log.Info("YUVA_ANALYTICS is off; analytics endpoints, the script and collection are not served")
} else if !cfg.Analytics.Monitor.On {
log.Info("YUVA_MONITOR is off; API properties and request records are not served")
}
if cfg.IngressAcceptV1 {
log.Warn("YUVA_INGRESS_ACCEPT_V1 is set; /ingress/email accepts v1 signatures, which do not cover the envelope sender")
Expand All @@ -285,7 +291,11 @@ func serve(ctx context.Context, cfg config.Config, log *slog.Logger, args []stri
if cfg.Analytics.On {
analyticsWorkers = cfg.Analytics.Workers
}
queue, err := jobs.New(st.Pool, st.Queries, log, cfg.BroadcastWorkers, cfg.MigrationWorkers, analyticsWorkers, srv.AddWorkers)
monitorWorkers := 0
if cfg.Analytics.On && cfg.Analytics.Monitor.On {
monitorWorkers = cfg.Analytics.Monitor.Workers
}
queue, err := jobs.New(st.Pool, st.Queries, log, cfg.BroadcastWorkers, cfg.MigrationWorkers, analyticsWorkers, monitorWorkers, srv.AddWorkers)
if err != nil {
return fmt.Errorf("job queue: %w", err)
}
Expand Down Expand Up @@ -332,6 +342,9 @@ func analyticsSettings(cfg config.Config) api.AnalyticsSettings {
Off: !a.On, Buffer: a.Buffer, VisitCache: a.VisitCache, SaltGrace: a.SaltGrace, MonthlyEvents: a.MonthlyEvents,
PropertyEventsPerMinute: a.PropertyEventsPerMinute, PropertiesPerWorkspace: a.PropertiesPerWorkspace,
RawDays: a.RawDays, RollupTop: a.RollupTop, GeoIP: a.GeoIP, GeoIPDir: a.GeoIPDir, GeoIPFile: a.GeoIPFile,
Monitor: api.MonitorSettings{Off: !a.Monitor.On, MonthlyRequests: a.Monitor.MonthlyRequests, RecordsPerMinute: a.Monitor.RecordsPerMinute,
BufferKeys: a.Monitor.BufferKeys, MinuteDays: a.Monitor.MinuteDays, Checks: a.Monitor.Checks, MinInterval: a.Monitor.MinInterval,
AllowPrivate: a.Monitor.AllowPrivate, CheckDays: a.Monitor.CheckDays},
}
}

Expand Down
35 changes: 34 additions & 1 deletion api/cmd/yuva/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const (
" yuva workspace broadcasts approve|deny|suspend --workspace <id|name> [--reason <text>]\n" +
" yuva workspace broadcasts limit --workspace <id|name> --daily <n> (0 removes the limit)\n" +
" yuva workspace broadcasts show --workspace <id|name>\n" +
" yuva workspace analytics-limit --workspace <id|name> <events|0> (0 removes the limit)"
" yuva workspace analytics-limit --workspace <id|name> <events|0> (0 removes the limit)\n" +
" yuva workspace monitor-limit --workspace <id|name> <requests|0> (0 removes the limit)"
analyticsUsage = "usage: yuva analytics property create --workspace <id|name> --name <name> --domain <host>... [--inbox <id|slug>] [--platform web|ios|android]... [--timezone <zone>]\n" +
" yuva analytics property list --workspace <id|name>\n" +
" yuva analytics geoip-update"
Expand Down Expand Up @@ -68,6 +69,9 @@ func operatorCLI(ctx context.Context, cfg config.Config, cmd string, args []stri
if len(args) > 0 && args[0] == "analytics-limit" {
return analyticsLimitCommand(ctx, st, srv, args[1:], os.Stderr)
}
if len(args) > 0 && args[0] == "monitor-limit" {
return monitorLimitCommand(ctx, st, srv, args[1:], os.Stderr)
}
return workspaceCommand(ctx, st, srv, args, os.Stderr)
case "person":
return personCommand(ctx, srv, args, os.Stderr)
Expand Down Expand Up @@ -516,6 +520,35 @@ func analyticsLimitCommand(ctx context.Context, st *store.Store, srv *api.Server
return nil
}

func monitorLimitCommand(ctx context.Context, st *store.Store, srv *api.Server, args []string, stderr io.Writer) error {
fs := flag.NewFlagSet("workspace monitor-limit", flag.ContinueOnError)
fs.SetOutput(stderr)
workspace := fs.String("workspace", "", "workspace id or exact name")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 1 {
return errors.New(workspaceUsage)
}
requests, err := strconv.ParseInt(fs.Arg(0), 10, 64)
if err != nil || requests < 0 {
return errors.New("the limit must be a number of requests, 0 for no limit")
}
ws, err := api.FindWorkspace(ctx, st, *workspace)
if err != nil {
return err
}
if err := srv.OperatorLimitMonitor(ctx, ws.ID, requests); err != nil {
return err
}
if requests == 0 {
fmt.Fprintf(stderr, "workspace %q (%s): API request records are not limited\n", ws.Name, ws.ID)
return nil
}
fmt.Fprintf(stderr, "workspace %q (%s): at most %d API request records a month\n", ws.Name, ws.ID, requests)
return nil
}

func analyticsCommand(ctx context.Context, st *store.Store, srv *api.Server, args []string, stdout, stderr io.Writer) error {
if len(args) < 2 || args[0] != "property" {
return errors.New(analyticsUsage)
Expand Down
2 changes: 1 addition & 1 deletion api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ require (
golang.org/x/net v0.60.0
golang.org/x/text v0.42.0
golang.org/x/tools v0.50.0
google.golang.org/protobuf v1.36.12
)

require (
Expand Down Expand Up @@ -148,7 +149,6 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
2 changes: 0 additions & 2 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
Expand Down
3 changes: 3 additions & 0 deletions api/internal/api/analytics_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ func (s *Server) SendAnalyticsEvents(ctx context.Context, req oas.SendAnalyticsE
if err != nil {
return nil, err
}
if err := siteProperty(prop); err != nil {
return nil, err
}
if req.Body == nil || len(req.Body.Events) == 0 || len(req.Body.Events) > maxAnalyticsBatch {
return nil, errValidation("events must hold 1 to 100 events")
}
Expand Down
45 changes: 38 additions & 7 deletions api/internal/api/analytics_collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

"github.com/productdevbook/yuva/api/internal/analytics"
"github.com/productdevbook/yuva/api/internal/mail"
"github.com/productdevbook/yuva/api/internal/monitor"
"github.com/productdevbook/yuva/api/internal/oas"
"github.com/productdevbook/yuva/api/internal/realtime"
"github.com/productdevbook/yuva/api/internal/store"
Expand Down Expand Up @@ -72,6 +73,7 @@ type AnalyticsSettings struct {
GeoIPFile string
// GeoIPURL replaces DB-IP's download address, for tests.
GeoIPURL func(month time.Time) string
Monitor MonitorSettings
}

func analyticsSettings(a AnalyticsSettings) AnalyticsSettings {
Expand Down Expand Up @@ -99,6 +101,7 @@ func analyticsSettings(a AnalyticsSettings) AnalyticsSettings {
if a.GeoIP == "" {
a.GeoIP = analytics.GeoOff
}
a.Monitor = monitorSettings(a.Monitor)
return a
}

Expand All @@ -115,6 +118,7 @@ type analyticsState struct {
props *propertyCache
usage *usageCache
limits *rateLimiter
monitor *monitorState
}

func (s *Server) newAnalytics(d Deps) *analyticsState {
Expand All @@ -129,13 +133,19 @@ func (s *Server) newAnalytics(d Deps) *analyticsState {
a.geo = &analytics.GeoIP{Mode: set.GeoIP, Dir: set.GeoIPDir, File: set.GeoIPFile, URL: set.GeoIPURL, Now: s.now}
}
a.flusher = &analytics.Flusher{Store: st, Buffer: a.buffer, Log: s.log, Now: s.now, AfterFlush: s.afterAnalyticsFlush}
if !d.Analytics.Monitor.Off {
a.monitor = s.newMonitor(d)
}
if d.Hub != nil {
d.Hub.OnInvalidate(a.invalidated)
}
return a
}

func (a *analyticsState) forget(propertyID uuid.UUID) {
if a.monitor != nil {
a.monitor.agg.Forget(propertyID)
}
a.props.forget(propertyID)
a.answers.forget(propertyID)
a.resolver.Salts.Forget(propertyID)
Expand Down Expand Up @@ -182,17 +192,34 @@ func (a *analyticsState) invalidated(payload string) {
}
return
}
if id, err := uuid.Parse(target); err == nil {
a.forget(id)
target, erased, _ := strings.Cut(target, monitorEraseMark)
id, err := uuid.Parse(target)
if err != nil {
return
}
if n, err := strconv.ParseInt(erased, 10, 32); err == nil && a.monitor != nil {
a.monitor.agg.EraseDim(id, monitor.DimConsumer, int32(n))
}
a.forget(id)
}

// RunAnalytics writes collected events until ctx ends and then drains what is left; it returns at
// once when analytics is off.
// RunAnalytics writes collected events and request rows until ctx ends and then drains what is
// left; it returns at once when analytics is off.
func (s *Server) RunAnalytics(ctx context.Context) {
if s.analytics == nil {
return
}
if m := s.analytics.monitor; m != nil {
if err := m.agg.Prepare(ctx); err != nil {
s.log.ErrorContext(ctx, "monitor partitions", slog.Any("error", err))
}
done := make(chan struct{})
go func() {
defer close(done)
m.agg.Run(ctx)
}()
defer func() { <-done }()
}
if err := s.analytics.flusher.Prepare(ctx); err != nil {
s.log.ErrorContext(ctx, "analytics partitions", slog.Any("error", err))
}
Expand Down Expand Up @@ -371,7 +398,7 @@ func (s *Server) analyticsUsage(ctx context.Context, ws uuid.UUID) (oas.Analytic
out.LimitReachedAt = u.LimitReachedAt
}
}
return out, nil
return out, s.monitorUsage(ctx, ws, &out)
}

// afterAnalyticsFlush tells owners and the operator, once a month, that a workspace reached its cap.
Expand Down Expand Up @@ -474,7 +501,7 @@ func (s *Server) analyticsCORS(w http.ResponseWriter, r *http.Request, next http
w.Header().Set("Access-Control-Allow-Origin", raw)
w.Header().Set("Access-Control-Expose-Headers", "Retry-After")
}
key := strings.TrimPrefix(r.URL.Path, analyticsPrefix)
key, records := strings.CutSuffix(strings.TrimPrefix(r.URL.Path, analyticsPrefix), requestsSuffix)
if key == "" || strings.Contains(key, "/") || len(key) > 200 {
writeProblem(w, errNotFound)
return
Expand All @@ -485,14 +512,18 @@ func (s *Server) analyticsCORS(w http.ResponseWriter, r *http.Request, next http
writeProblem(w, errInternal)
return
}
if cp == nil {
if cp == nil || isAPIProperty(cp.prop) != records {
writeProblem(w, errNotFound)
return
}
if raw == "" && r.Method == http.MethodPost && cp.apps() {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), analyticsPropertyKey, cp)))
return
}
if records {
writeProblem(w, errPropertyOrigin)
return
}
host := ""
if origin, ok := normalizeOrigin(raw); ok {
if u, err := url.Parse(origin); err == nil {
Expand Down
Loading
Loading