Operations notes
Image tags
| Tag | Meaning | Use for |
|---|---|---|
:<version> | immutable release | production (pin this) |
:latest | newest release | quickstarts |
:rc | floating, rebuilt on every merge to main | tracking development — at your own risk |
Restarts and data
In manage mode (riptide.clickhouse.manage-schema=true, the default), Riptide ensures its
flows table with CREATE TABLE IF NOT EXISTS at startup — an existing table is not
replaced, so flow data now survives a Riptide restart. (This fixes the earlier
CREATE OR REPLACE behavior, which recreated the table on every boot and lost all data.)
Schema evolution is still not migrated automatically. CREATE TABLE IF NOT EXISTS no-ops on
an existing table, so a schema change between Riptide versions is not applied — the
startup column check fails fast if the on-disk schema is stale, and the operator must drop
the flows table (Riptide recreates it in manage mode) or re-provision it in
manage-schema=false mode. This is a deliberate fail-fast until schema migrations land; plan
retention accordingly if a version upgrade requires dropping the table.
Config hot-reload
Credential sets, polling profiles and routing reload from /etc/riptide/config.yaml
without a restart, and the inventory file
reloads on its own content changes — adding a device or carving a range out applies
within one poll. Opt in with:
riptide.config.reload-interval=30s # absent or 0 = disabled (the default)
Semantics:
- Content-hash polling, not inotify — the path is re-resolved and the content hashed every cycle, so bind mounts, Kubernetes ConfigMap symlink swaps, and mtime-insensitive writers are all picked up reliably.
- Layering is preserved — environment-variable overrides keep their precedence over the file, exactly as at boot. A file created after startup slots in beneath the environment as well.
- Bad config never wins — candidates run the same validation as startup; a failing
reload keeps the running configuration, logs a warning naming the problem, and
raises
config.reload.failuresplus aconfig.reload.stalegauge (alert on it). - A missing, empty or whitespace-only file skips the cycle — deletion is
indistinguishable from an atomic replacement in progress, and a shell
>redirect truncates before writing, so the running config is kept and nothing is counted as a failure. A file holding nothing but a UTF-8 byte-order mark counts as empty here: the mark is removed when the file is read, so an editor that truncates a file it had BOM-prefixed skips like any other truncation instead of committing an empty configuration. Both the config file and the inventory file behave this way. The skip warns once per episode, not once per poll. Removing the file layer for real requires a restart. - A skipped cycle leaves the gauges where they were — a skip decides nothing about
whether disk and serving agree, so
*.reload.staleis not recomputed and not latched. A file that has been truncated for an hour therefore readsstale=0. Alert on the once-per-episode warning above, or on the absence of successful reloads; the stale gauge answers "did the last file we could read commit", not "is the file on disk serving". - On a successful reload the SNMP interface cache and the SOPS decrypted-file cache
refresh; exporter-pushed interface names (option records) are kept — they describe
devices, not configuration. Reloads trigger on config-file changes only: after
rotating a SOPS secrets file, touch or edit
config.yamlso the decrypted cache drops and the next poll picks up the new secret. - The gauges exist only while reloading is enabled —
config.reload.stale/inventory.reload.staleand the dead-schedule gauges below are absent when reloading is disabled. They are registered by the first start and are deliberately not removed by a stop, so a stopped schedule stays visible rather than vanishing. That cuts both ways: after a stop,*.reload.deadreads 1 (see below) while*.reload.stalefreezes at whatever it last computed — commonly0, which this page otherwise defines as "the last file we could read is what is serving". Neither gauge is meaningful once the schedule has stopped; readdeadfirst. Absence means "not watching"; a 0 means "the last file we could read is what is serving" — not "the file on disk is serving", because a skipped cycle reads no file and changes no gauge. Alert on absence separately if hot-reload is mandatory in your deployment. - A dead schedule is visible —
config.reload.dead/inventory.reload.deadread 1 if the poll schedule stopped and will never run again (the realistic cause: anErrorsuch as OOM on an oversized file mid-read). Alert on> 0; the only recovery is a restart. A deliberate shutdown reads 1 too — the gauge says the schedule is not running, not why — so scope that alert to processes you expect to be up, or it fires once on every planned stop. - Shutdown counts nothing — an interrupt landing mid-poll during an orderly stop is not a reload failure: no counter moves and no stale latch is set.
Limitations: profile-activated YAML documents and nested spring.config.import inside
the reloaded file are boot-only; env:// secret references cannot rotate in-process
(the environment is immutable per process) — those need a restart.
A whitespace-only config.yaml used to increment config.reload.failures and latch config.reload.stale.
It is now a skip, matching this page's description and what the inventory file has always done.
If you alert on config.reload.failures to catch a > config.yaml truncation, that alert stops firing: the truncation now surfaces as the once-per-episode warning above and as reloads that stop happening, not as a failure.
Classification rule reloads
The classification rules are a separate family with a separate posture, and their own opt-in schedule.
The resource named by riptide.classification.rules is parsed once, eagerly, while the context starts: an unreadable or unparseable resource fails the boot there, naming the parse error.
For an http(s):// resource that eager parse is a network fetch, so a rules server that is down is a startup outage — the collector will not come up until it answers. Weigh that against the convenience of serving one ruleset to a fleet; a local file with a configuration-management tool writing it has no such coupling.
The engine then loads those rules into its decision tree on a background thread — but on that first load, "background" does not mean "invisible": every thread that tries to classify blocks until it finishes.
That build is the cost that grows with the size of your ruleset, and it is bounded: see Supported ruleset size below.
Afterwards, nothing re-reads the resource unless you configure an interval:
riptide.classification.reload-interval=5m # absent or 0 = disabled (the default)
With an interval set, the rules resource is polled on that schedule and a change applies without a restart.
The resource is any Spring resource location, so this covers file:/etc/riptide/classification-rules.csv as readily as an http://rules.internal/riptide.csv endpoint serving one ruleset to a fleet.
Point it at a file or a URL, not at the bundled classpath: default: a classpath resource inside the packaged jar cannot change, so the schedule polls it forever and never has anything to apply.
Semantics, which are the config reloader's (same poll loop) with a source that can be a URL:
- Content-hash polling — the resource is re-resolved and its bytes hashed every cycle. Unchanged bytes rebuild nothing: the hash decides, not the clock. A cycle that finds no change costs one fetch and no work; a cycle that finds one costs two, because the engine re-reads the resource itself when it rebuilds. Startup costs three (the eager parse, the engine's first load, and the schedule's own baseline).
- A remote fetch is bounded end to end — 10 seconds to connect, 10 seconds for each read, and 10 seconds for the whole response. The last of those is the one that matters: a server sending one byte at a time resets a per-read timer forever, so only a deadline across the response ends the cycle. Worst case is roughly twice the bound, because a read already blocked when the deadline passes still has to time out on its own. A response larger than 8 MiB is refused unread rather than buffered.
- Only a 200 is a ruleset — any other status is a failure naming the code, so a redirect this fetch does not follow, a 5xx, or a proxy's error page served as HTML never reaches the CSV parser. The one exception is 404, which is absence and skips.
- A source that is not there skips — a 404, or a deleted file. The last good rules keep classifying, nothing is counted as a failure, and the skip warns once per episode rather than once per poll. So does a response with an empty or whitespace-only body: an empty ruleset is never committed. A local file that is present but unreadable (a permission denial) is a failure, not a skip — telling an operator to make a file reappear when it is already there would send them the wrong way.
- A failed fetch or a failed load keeps the last good rules serving — flows keep being classified by whatever loaded last, and nothing is thrown at a flow. See the two cases below.
- A condition column must name something Riptide can resolve — a
protocol, port or address column that is not empty but resolves to nothing is rejected, and the rule classifies nothing. Name protocols by keyword, not by number:tcp, not6. One bad keyword refuses the whole rule, sotcp,tpcis refused rather than quietly narrowed totcp. Leave a column empty to mean "any" — that is the only way to say it. The keywords Riptide accepts are IANA's, with two deliberate exceptions it still carries under the older name: 55 isMOBILE(IANA renamed itMin-IPv4) and 84 accepts bothTTPandIPTM. See issue 763. - The
exporterFiltercolumn must be left empty — the column is part of the required header and cannot be removed, but no matcher evaluates a value in it. A rule carrying one is rejected and classifies nothing, named in the WARN below. It is refused rather than honoured because a dropped condition would apply the rule to every exporter instead of the one it names, which is a silently wider match rather than a visible failure. Per-exporter scoping does not exist today; see issue 759. - What was published is logged, and rejected rules are named — every load that publishes logs how many rules it published, and a WARN naming any rule the engine could not use. A rejected rule is not a failed reload: the rest of the ruleset serves,
classification.reload.successesmoves andclassification.reload.stalestays 0, so every other metric reads healthy. Alert onclassification_rules_rejected > 0— that is the one series which says part of an edit is classifying nothing. The WARN is still where you learn which rules and why (the ERROR beside it names the column and value), and it names the rejected rules up to the first 20 before summarising as a count. Neither the gauge nor the log line depends on the interval: the ruleset loaded at startup is reported the same way whether or not a schedule is configured. - A ruleset that failed to load is attempted once — not once per interval. Bytes that would not parse this cycle will not parse next cycle, so a retry loop would rebuild nothing and bury the first, real failure under one per interval. The failure stays counted and holds the stale gauge at 1; fix the ruleset and the next poll picks the fix up as an ordinary change.
- No authentication — no credentials are sent, no conditional
GET, no ETag orLast-Modifiedhandling; the endpoint must answer an unconditionalGET. Protect it at the network layer. Credentials embedded in the location (http://user:token@…) are not a supported way to authenticate; they are redacted wherever Riptide logs the location, but they still travel in the clear.
Rules that loaded before may start being rejected after an upgrade. There are two cases, and they were misbehaving in opposite directions — check which one you have before assuming the rejection is good news.
The column resolved to nothing at all — protocol=tpc, protocol=6 (a number where a keyword belongs), protocol=*, or a stray , in a protocol, port or address column. The condition was dropped: the rule matched every protocol, port or address, silently and with nothing logged. Such a rule was matching far more traffic than it named, and rejecting it is a straight improvement.
The column resolved in part — protocol=tcp,tpc. This was not widened: the rule matched TCP and nothing else, so it was quietly matching less than it named. It is now refused outright, so it matches nothing at all until you fix the typo. This is a regression for that rule, and the only one in this change: traffic it used to classify stops being classified. Fix the keyword and it returns.
Either way the WARN names the rule; the ERROR beside it names the column and the offending value.
| Metric | Meaning |
|---|---|
classification.reload.successes | Loads that published a ruleset. A healthy start leaves this at 1. |
classification.reload.failures | Reloads that did not happen: a fetch that failed, or a load that threw. Not latched: every attempt that fails counts again. |
classification.reload.stale | 1 when the last fetch or load attempt failed and no later one has succeeded; 0 otherwise. |
classification.reload.dead | 1 if the poll schedule stopped and will never run again, including after a deliberate shutdown. Registered once an interval is configured, and kept after a stop. |
classification.rules.rejected | Rules in the serving ruleset that classify nothing, because the engine could not use them. Alert on > 0. -1 means no ruleset has ever been published — see below. |
classification.rules.published | Rules in the serving ruleset, rejected ones included, so the count above is readable as a proportion. -1 on the same condition. |
classification.rules.preprocessed | The same ruleset counted the way the tree build works on it, with reversed rules included, so it is roughly double the row count for an omnidirectional ruleset. This is the number the size bound is about — see Supported ruleset size. -1 on the same condition. |
Why the rule gauges read -1 and not 0. -1 is "no ruleset has ever been published", which is not the same as "nothing was rejected" — a 0 in that state would claim a ruleset that loaded cleanly. Read classification.reload.stale alongside to tell the two cases apart:
-1withstale=0— the boot load has not published yet. The engine submits it asynchronously, so this window is normal and resolves on its own. It is not free while it lasts: until the initial load publishes, every thread calling into classification blocks, so the window is as long as the first tree build takes — see Supported ruleset size.-1withstale=1— the initial load failed. There is no publication at all and the collector is classifying nothing. This one does not resolve until a reload succeeds.
Neither fires an alert on rejected > 0, which is deliberate: a collector with no rules at all is what stale is for. Like classification.reload.stale and unlike classification.reload.dead, these two are registered whether or not a reload interval is configured, because a rejected rule is reported at boot either way.
One family, two layers. Fetching the rules and loading them are done by different parts — the reload schedule fetches, the engine loads — and they report on the same three series rather than on two families that could disagree.
They cannot double-count: a fetch that fails never reaches the engine, and a ruleset that fails to parse was fetched successfully.
classification.reload.stale covers both halves, so 1 means "the rules that are serving are not the rules the source has", whichever half is at fault; the log line names which.
A skipped cycle leaves the gauges where they were. A skip decides nothing about whether the source and what is serving agree, so classification.reload.stale is not recomputed and not latched: an endpoint that has answered 404 for an hour reads stale=0, and so does a ruleset that has been empty all day. This is the same trap the config reloader carries, and it matters more here because the whole page tells you to alert on stale. Alert on the once-per-episode warning as well, or on the absence of successful reloads.
A dead schedule is visible. classification.reload.dead reads 1 if the poll schedule stopped and will never run again (the realistic cause: an Error such as OOM mid-cycle). Alert on > 0; the only recovery is a restart. As with the config and inventory schedules, a deliberate shutdown reads 1 as well — the gauge reports that the schedule is not running, not why — so scope the alert to processes you expect to be up.
Unlike config.reload.stale and inventory.reload.stale, classification.reload.stale is registered unconditionally, including when no interval is configured.
It claims less than they do: they assert a relationship between a file on disk and what is serving, so a permanent 0 would falsely read "in sync", while this one asserts only "the last attempt failed and has not recovered".
With no interval, 0 is simply true.
classification.reload.dead follows the other reloaders instead and is absent with no interval configured — a dead-schedule gauge reading 0 would claim there is a schedule.
What a failure does depends on whether any rules ever loaded:
- Rules already serving — nothing an operator or a flow can see changes. A rebuild publishes atomically, so a failed one leaves the previous rules classifying, complete. The failure is reported by a WARN naming the cause, plus the counter and the gauge. This is the case where the gauge is the only durable signal: no flow fails and no error is logged.
- No rules ever loaded — classification is unavailable. Every flow's classification throws and an ERROR is logged. Reaching this needs the resource to become unreadable between the eager startup parse and the background load a moment later; the window is narrow, but the context starts normally and stays up, so the signals are the ERROR,
classification.reload.staleat 1, and both rule gauges at-1. With a reload interval configured this recovers on its own in the ordinary case: the schedule could not read a baseline either, so the first poll that reads anything hands it to the engine. Only if the resource became readable in the window between the schedule taking its baseline and the next poll does recovery wait for the rules to actually change, because from then on the hash decides. Restarting once the resource is readable resolves both.
Shutdown counts nothing here either: a reload interrupted or refused during an orderly stop moves no counter and latches no gauge.
Dots become underscores at /metrics (see Metrics endpoint), so the series to alert on are classification_reload_stale, classification_reload_dead, classification_rules_rejected and classification_rules_preprocessed — the dotted names above are the registry's, not PromQL's.
Supported ruleset size
Riptide supports classification rulesets of up to 12,500 rules — the largest size the tree build has actually been run at (12,496 rules), rounded up. Nothing refuses a larger one: it still loads and still builds, and every rule in it still classifies. What the bound says is that past this point no measurement backs the cost, and the growth below is steep enough that guessing is a bad idea.
You are told when you cross it. Every publish that exceeds the bound logs a WARN naming both counts and what it costs you, and classification.rules.preprocessed carries the same number for alerting. Crossing it is a decision, not a fault, so nothing fails and no gauge latches — but it is no longer something you have to read this page to discover.
The trigger is the preprocessed count, not the row count, because that is what the build works on — and the two differ by up to a factor of two, which the next paragraph explains. So the 12,500 above is the bound for a ruleset shaped like the shipped one, where every rule is omnidirectional; read against 25,000 preprocessed rules it covers both shapes. A ruleset of 20,000 rules that are not omnidirectional preprocesses to 20,000 and does not warn; 12,600 omnidirectional ones preprocess to 25,200 and do. Alert on classification_rules_preprocessed > 25000 if you want the condition rather than the log line.
Expect a build of four to five seconds at the bound. That is bracketed rather than measured outright, because the only ruleset that size is a synthetic one: building it took 4.43 s, and extending the growth curve from the real shipped ruleset predicts 4.8 s. The two agree, which is as much confidence as there is to be had without a real ruleset of that size to build.
Count preprocessed rules, not rows. What the build works on is the preprocessed list, and an omnidirectional rule carrying a port or address condition is built in both directions, so it counts twice. Every rule in the shipped ruleset is omnidirectional, which is why its 6,248 rows become 12,496. So the bound is really about 25,000 preprocessed rules: 12,500 omnidirectional rules reach it, and roughly 25,000 one-directional rules reach it too. You do not have to work out which you have — the engine logs both counts on every load.
At startup the build blocks classification. On a reload it does not. Before any ruleset has ever published, a thread that calls into classification waits for the first build to finish, so the figures below are how long after startup classification begins answering. Once a ruleset has published, a rebuild runs beside it: the previous rules keep classifying, complete, and the build time is how long a rule edit takes to take effect, not a stall.
| Ruleset | Rules | Preprocessed | One Tree.of build | maxDepth | avgComp |
|---|---|---|---|---|---|
Bundled — the shipped classification-rules.csv | 6,248 | 12,496 | 929 ms ± 7 | 14 | 14.71 |
| Synthesised ×1 | 6,248 | 12,496 | 921 ms ± 10 | 13 | 14.69 |
| Synthesised ×2 | 12,496 | 24,992 | 4.43 s ± 0.07 | 15 | 16.00 |
| Synthesised ×4 | 24,992 | 49,984 | 24.1 s ± 0.6 | 16 | 17.30 |
Only the bundled row is a real ruleset. The rest are that same ruleset cloned, with each clone's ports remapped so no two clones share one — a synthetic ruleset of a given size has different threshold cardinality than a real one of that size would, because real rulesets cluster on well-known ports and a clone spreads evenly. Take the shape of the growth from those rows, not the seconds. The ×1 row is what makes that checkable: same rule count, built the synthetic way, 921 ms against the bundled 929 ms — under 1 % apart in time, though not identical in shape (it builds a 13-level tree where the real ruleset builds a 14-level one). At equal size the synthesis is neither cheap nor dear; whether that still holds at ×4 is exactly what is not known.
The algorithm is quadratic; the per-unit cost degrades on top of that. Counting the work the build does — candidates scored times rules at each node, a deterministic quantity that owes nothing to the machine, the core count or the JIT — gives 262,251,844 for the shipped ruleset. On the synthesised sizes it is 262,290,026 / 1,048,497,458 / 4,192,936,322 at ×1 / ×2 / ×4: ratios of 3.998 and 3.999, an exponent of 2.00.
Two limits on that, because it is the kind of number that invites over-reading. The ratios come from two doublings of synthesised rulesets, and the clone is built so its distinct-port count scales exactly linearly — so part of that flatness is the construction, not the algorithm.
What that limit can be checked against is the ×1 row, on the same work metric: the real shipped ruleset costs 262,251,844 and the synthesis of the same size costs 262,290,026, 0.015 % apart. So the clone reproduces what a real ruleset of that size costs the build, which is a stronger agreement than the 1 % the wall times manage. It does not carry upward: agreement at equal size says nothing about whether the linear port scaling flatters the ×2 and ×4 ratios, and no real ruleset of those sizes exists to check against. The suite's own ratio check on real rules is not a second control here — its slices scale their distinct-port count linearly too (199 ports at 200 rules, 397 at 400), so it shares the property rather than testing it. What that check does is keep the counter honest about tracking the whole quadratic term; it is not independent evidence about the construction.
What the work count does establish is where the extra time comes from. Work grew ×4.0 per doubling while time grew ×4.81 then ×5.43, so the time spent per unit of work rose about 1.20× and then 1.36×. That is a cost per unit that worsens with size, not a constant factor — and this change did not measure why. Allocation and GC at a pinned 4 GB heap, cache behaviour, and scheduling in the common pool are all consistent with it; none was tested.
Doubling the ruleset costs about five times the build, not twice. Measured: ×4.81 across the first doubling and ×5.43 across the second. As an exponent that is 2.27 then 2.44 — superlinear, and rising rather than constant. Applying the average of the two to the one real anchor, the 929 ms bundled build, puts ten times the shipped ruleset at roughly three and a half minutes. Read that as a floor, not an estimate: the timed exponent grows with size, so a single figure understates the cost above ×4. The work exponent does not grow (see above) — what grows is the time each unit of work takes.
The tree gets deeper too, but slowly — about one level per doubling. Average depth went 11.85 → 12.90 → 13.94 across ×1, ×2 and ×4, and the average comparisons a request costs went 14.69 → 16.00 → 17.30. So per-flow work does grow with the ruleset, and it grows logarithmically while the build grows superlinearly. That is the point of the tree, and it is why the build is the cost worth bounding. The maxDepth and avgComp columns above come from the same benchmark run as the times; the engine logs the same fields for your ruleset on every load.
What these figures do not cover. Five things, each of which would need its own measurement:
-
What the work count counts. It is candidates-scored × rules-at-that-node, and nothing else: not the cost inside a verdict, not candidate enumeration or deduplication, not the bounds check, not the classifier sort in a leaf, and — deliberately — not parallelism, since the whole point of the quantity is that it does not vary with core count. So it is the algorithm's dominant term rather than the build's total work, and a change that made every verdict twice as expensive would move the seconds without moving it. Read it as the shape of the growth; read the timed rows for the cost.
-
One rule shape. Every measured ruleset consists of rules that constrain a single destination port and nothing else, because that is what the shipped ruleset is and what the clone can reproduce without collisions. A ruleset using address conditions, port ranges, or source-port conditions builds a differently shaped tree and is unmeasured here.
-
Tree.ofonly. A reload also reads the resource and runs the preprocess loop, and the benchmark deliberately excludes both. The published number is a lower bound on the reload, not the reload. -
Heap. The benchmark pins
-Xmx4gso its runs are comparable. How much heap a ruleset at the bound actually needs was not measured, and the tree at ×4 holds about 32,000 nodes and 64,000 leaves. -
CPU. The build scores its split candidates on a parallel stream, so it uses every core the JVM's common pool has for as long as it runs. On a busy collector that is contention with the ingest path, and on a small one it is a longer build.
Nothing here was checked by classifying a flow: the benchmark builds trees and discards them. What the rows support is that the build completes, in that time, at that size.
Prefer your own number to this table. The collector already reports it, for your real ruleset on your real hardware, and no interpolation beats that:
- the
calculated flow classification decision treeINFO line the engine logs after every build, which carriestime (ms),rules(with the reversed count beside it),nodes,maxDepthandavgComp— the same fields as the table above; - the
reloadtimer in the metrics registry, which spans the whole reload — resource read, preprocessing and build — and so is the number this table is only a lower bound on.
Mind the build against riptide.classification.reload-interval. A poll that finds changed bytes while a build is still running cancels that build and starts again from the new bytes. Unchanged bytes rebuild nothing, so a short interval is harmless on its own — but a ruleset being rewritten repeatedly, on an interval shorter than the build takes, can keep pre-empting itself and never publish. At the bound the build is around 4.4 s, so keep the interval comfortably above it if your rules source changes often.
Provenance. Wall-time figures measured on 2026-09-06 at commit 227a4011; the work counts were added later (#768) and measured on 2026-09-07 on the same machine, by make bench-jmh BENCH_TARGET=TreeBuildBenchmark (source: src/test/java/org/riptide/benchmarks/classification/TreeBuildBenchmark.java), on a 10-core Apple M1 Max laptop with JDK 25 and -Xmx4g. The sample size comes from that target's default BENCH_OPTS (-wi 3 -i 10 -f 2 — 2 forks, 3 warmup and 10 measured single shots each, so 20 samples per row), not from the annotations on the class, which are lighter; running the class straight from an IDE gives a much smaller sample. The ± is JMH's 99.9 % confidence interval. The benchmark is not part of any build gate, so these numbers only change when somebody deliberately re-measures. Two caveats on transferring them: the parallel build makes them core-count dependent, and the reported score is a JIT-warmed build rather than the genuinely cold first build a boot performs. The run prints its cold shot per fork as # Warmup Iteration 1; that came out about 20 % above the warm score at the bundled size and slightly below it at ×2 and ×4.
Upgrading
Compose: docker compose pull && docker compose up -d. Plain JAR: replace the jar,
restart. Configuration is backward-compatible within a minor line; breaking configuration
moves are impossible to miss: the removed trees fail startup (riptide.nodes and the
retired fleet poll keys), while superseded-but-harmless ones log an explicit error and are
ignored (riptide.snmp.config.definitions). The 0.9 flag day is the big one:
any surviving riptide.nodes key, in any spelling including the RIPTIDE_NODES_*
environment form, stops the collector with an error naming the key and the converter —
see Upgrading from 0.8. Plan it as a migration step, not as a
log-review item: under systemd or Kubernetes a missed key means a restart loop until the
configuration is converted.
Upgrading to 0.7.0 also changes what one metric means rather than any configuration key — see
Parser gauges: exporters and templates before relying on
parsers.<name>.sessionCount.
NetFlow v5 sampling rates change on upgrade. riptide now reads the sampling rate a v5 exporter
states in its packet header, where it previously ignored it. Stored bytes and packets are
untouched and nothing fails, but any query multiplying by samplingInterval returns a different
answer for v5 rows written from here on, and older rows are not rewritten. Watch
parsers.<name>.samplingRate.header to see which receivers are now resolving from the header. Full
detail, including how to identify affected exporters and how to pin the old behaviour, is in
Sampling rate.
A samplingProvenance column is added to flows on upgrade. It records which rung of the
resolution ladder supplied each row's samplingInterval, so a stored 1 stops being ambiguous
between an exporter that said it does not sample and one that said nothing at all. In manage mode
the column is added in place with ALTER TABLE … ADD COLUMN IF NOT EXISTS: no operator action, no
data loss, no rewrite. A provisioned deployment (manage-schema: false) fails fast naming the
column — re-run riptide onboard to add it. Existing rows read '', which means "written before
this column existed" and is deliberately distinct from assumed; they are not backfilled, because
the information needed to reconstruct them was never recorded. No existing column, value or query
result changes. See Where a rate came
from.
Ingest loss counters
Flows can be dropped at two bounded queues, and each one counts what it discards — nothing is lost silently. Alert on the drop counters; watch the depth gauges for early warning.
| Metric | Meaning |
|---|---|
listeners.<name>.socketDrops | datagrams the kernel discarded because the socket receive buffer was full (gauge, Linux only) |
parsers.<name>.undecodableSets | Data Sets discarded because their IPFIX/NetFlow v9 Template was not known |
parsers.<name>.dispatchQueueDepth | packets waiting to be enriched (gauge) |
parsers.<name>.dispatchDrops | records discarded because enrichment/persistence fell behind, or discarded at shutdown |
parsers.<name>.unmodelledElementTemplates | IPFIX templates announcing an information element riptide parses and then discards. Not an error — see below |
pipeline.dispatchErrors | records lost because enrichment or persistence threw |
persister.batch.queueDepth | rows waiting to be inserted (gauge) |
persister.batch.droppedRows | rows the queue never handed to an insert |
persister.batch.failedRows | rows an insert was attempted for, and lost |
persister.batch.deadLetteredRows | rows of a refused batch kept in flows_dead_letter instead of being dropped |
persister.batch.deadLetterFailedRows | rows of a refused batch that could not be kept either |
The two persister.batch.* loss counters split on whether an insert was ever attempted, which is
the distinction to reach for when deciding which one you are looking at:
droppedRows | failedRows | |
|---|---|---|
| when | no insert was attempted for the row | the flusher had the row and did not deliver it |
| causes | queue full, repository stopping, producer interrupted, offered after the shutdown drain | insert refused, unexpected Error in the flusher, flusher interrupted mid-drain, shutdown grace expired |
| exact? | yes — nothing was sent | no in two of four cases, see below |
| what it means | ClickHouse cannot keep up, or riptide is shutting down | ClickHouse rejected the write, or riptide died holding it |
failedRows covers four cases, and is an upper bound on the loss rather than an exact count of it in two of them.
A refused insert may still have committed a prefix of the batch, yet the whole batch is charged here (see insert batching); the same is true when an unexpected Error escapes the flusher, since it may escape with an insert already in flight.
The other two are certain loss: rows the flusher still held when it was interrupted, and rows left over once the shutdown grace period expires. Neither ever reached the server.
Dead letters
A refused insert — the first of those four cases — no longer discards its rows, as long as batching is on (riptide.clickhouse.batch.enabled, the default).
With batching off there is no flusher, no batch and no dead letter: the rejection reaches the caller synchronously, which is the signal batching removes and dead-lettering replaces, and the records are counted in pipeline.dispatchErrors instead. That path also inserts one call at a time, so a poison row costs that call rather than up to max-rows flows.
The flusher writes every row of the batch to flows_dead_letter and counts them under deadLetteredRows; if that write fails too, the rows are counted under deadLetterFailedRows and the behaviour is exactly what it was before the table existed.
failedRows still charges the whole batch either way, because a dead-lettered row is still not in flows.
So on a refused batch, failedRows − deadLetteredRows is what riptide no longer has anywhere.
deadLetterFailedRows moving at all is itself worth looking at: the commonest cause is a deployment provisioned before this table existed, which is fixed by re-running riptide onboard --create-schema (see multi-tenancy).
Restart the collector after adding the table. Once the server has answered that the table is not there, riptide stops asking — an un-migrated deployment would otherwise spend a round trip per refused batch to be told the same thing — and it reports that once, naming the remedy. Like the rollups, the posture is decided while the process runs and re-read at startup.
The other three failedRows cases are not dead-lettered, deliberately — none of them is a batch a reachable server refused, and each is explained where it is counted.
A dead letter is replayed by an operator, deliberately, and never by riptide.
There is no automatic re-insert and there is no flag to turn one on.
The reason is the rollups: they are SummingMergeTree targets fed by materialized views on flows, and their retention deliberately outlives the raw table's, so a row re-inserted into flows is summed into aggregates that survive the raw rows needed to diagnose the inflation.
A refused insert is also not always atomic, so riptide cannot tell which rows of the batch the server already kept.
Read the dead letters, decide, and insert what you mean to insert:
-- What was refused, and why
SELECT tenant, failedAt, error, count() AS rows
FROM riptide.flows_dead_letter
GROUP BY tenant, failedAt, error
ORDER BY failedAt DESC;
-- One batch's rows, as JSON
SELECT payload FROM riptide.flows_dead_letter WHERE failedAt = '...' AND tenant = '...';
It does not rescue a batch lost to a severed transport.
The dead-letter write goes to the same server over the same client, so when the connection is the problem it fails too and the rows are counted under deadLetterFailedRows.
What it addresses is the server that is reachable and refuses the batch — a poison row, a constraint violation, a quota.
flows_dead_letter carries the same tenant row policy as flows, so a tenant reads only its own dead letters.
It carries no CHECK constraint, which is the point: its job is to accept rows flows refused, and the commonest refusal is that constraint firing.
One consequence follows and is worth knowing: a writer whose config lies about its tenant has its write refused, and the dead letter it files is then visible to the tenant it named.
The write itself is still refused; what changes is that the attempt leaves evidence.
Delivery accounting: recordsReceived − dispatchDrops − dispatchErrors is what reached the
persister.
Start from recordsReceived, not recordsScheduled. dispatchDrops counts two populations
and only one of them is in recordsScheduled: a packet refused by a full queue is charged to
dispatchDrops and returns before the scheduled mark, while records abandoned at shutdown were
scheduled first. Subtracting all of dispatchDrops from recordsScheduled therefore removes the
queue-full records twice and understates delivery — by exactly the amount that matters, since the
queue-full term is the one that grows under the overload these counters exist for. Measured on the
saturation case in ParserDispatchTest: received 9, scheduled 6, dropped 3, actually delivered 6 —
received − drops gives 6, scheduled − drops gives 3.
Note recordsDispatched does not exclude dispatchErrors: the dispatcher catches
the failure and returns normally, so the records are marked dispatched and counted as errors both.
Do not read that meter as delivery confirmation — subtract, or use the drop counters directly.
It disagrees downwards too, in two places: an Error escaping the dispatcher skips the mark, and
records abandoned at shutdown were scheduled and never dispatched. DaemonDispatcherTest and
ParserDispatchTest pin both directions.
That arithmetic stops at the persister: do not extend it to persisted rows by subtracting failedRows, because a refused insert counted in full there may have committed part of its batch. Query the table for what landed. Nor does adding deadLetteredRows back repair it: a dead-lettered row is in flows_dead_letter, not in flows, and the prefix the server may have committed is counted in both.
Is failedRows alertable? Yes, on a sustained rate — but as a signal, not as a loss figure.
A non-zero rate means ClickHouse is rejecting writes riptide had already accepted, which is worth
paging on however many rows it turns out to be. Do not put the number in the alert text as flows
lost: it is an upper bound, and in the refused-insert case some of those rows are in the table.
Since dead-lettering, a refused batch's rows are also in flows_dead_letter — so quote
deadLetterFailedRows if the alert needs a number that is closer to "gone".
It is deliberately outside the readiness contract, like the rest of the ClickHouse path, so it
will not fail /readyz — these metrics are the whole story.
Two of these count loss that happens before any of the queues. They were added because a lab measurement found the application accounting for only ~4% of a ~25% shortfall under sustained overload, with nothing accounting for the rest:
socketDropsis upstream of every application counter. Once the receive buffer overflows, the datagram is gone before riptide runs, so this is the only place that loss is visible at all. It is read per socket from/proc/net/udp, matched on the bound address and port, so it attributes to this receiver rather than to the whole host or to another socket sharing the port number. It publishes no value on non-Linux platforms (absent is not the same as zero). A rising value means the collector cannot drain the socket fast enough: raisenet.core.rmem_max, or reduce offered load.undecodableSetscounts Data Sets thrown away because their Template had not arrived. RFC 7011 §8 permits discarding these, so it is not a protocol error, but it is still lost data. It counts Sets, not records: without the Template the record size is unknown, so treat it as a lower bound. Note that it also counts Options Data Sets, whose loss costs enrichment metadata (exporter-pushed interface names) rather than flow records, so a non-zero value is not proof that flow data was lost. Expect a burst at startup: a UDP exporter re-announces Templates only periodically, so a freshly started collector discards data until the first Template of each exporter arrives. Sustained non-zero values are the ones to alert on.
Datagram vs. reliable transports differ deliberately. A UDP receiver drops when its dispatch queue stays full, because the medium is already lossy and a counted userspace drop beats pushing back into the kernel receive buffer where the loss is invisible. An IPFIX/TCP receiver never drops here — the exporter's bytes are already acknowledged and there is no retransmission, so the listener blocks instead, which closes the TCP receive window and makes the exporter slow down.
Memory budget for the queues
Both queues are bounded, so the worst case is the sum, and the dispatch queue costs more than its flow objects:
parsers.<name>dispatch queue: 4096 packets by default. Each queued packet also pins its received datagram buffer until the packet is enriched — about 33 MB of direct memory per receiver at the default 8096-byte buffer size, on top of the heap cost of the flow objects.persister.batchqueue: 40,000 rows by default (riptide.clickhouse.batch.queue-capacity).
A multi receiver runs one parser per sub-protocol, each with its own queue and threads, so budget
per sub-protocol and size down accordingly if you configure several.
Elements riptide parses and discards
parsers.<name>.unmodelledElementTemplates counts IPFIX templates carrying an information element riptide understands well enough to parse, and then deliberately does not use.
A non-zero reading is not a fault. Nothing is dropped, no flow is lost, and the export is valid. It means an exporter is stating something riptide is not reading, and somebody should decide whether that matters.
The log line is per element, per exporter, per parser. Each watchlisted element is named once for each exporter that announces it, by each parser that sees it. Both halves of that matter: an exporter announcing IE 396 must not silence the IE 390 arrival this exists to catch, and a lab box announcing IE 390 last year must not silence a production box announcing it today. The line names the exporter address and observation domain, because the whole point is that somebody goes and looks at it.
Alert on the total, not on a rate. This is a monotonic counter for the life of the process, like undecodableSets beside it. A rate computed from it is meaningful over UDP, where an exporter re-announces its templates on a timer, and misleading over TCP, where templates are announced once per connection: the count stops moving while that exporter carries on exporting flow-selection data for the life of the session. A flat rate on TCP does not mean the condition cleared.
Today the watchlist holds one family: the IPFIX flow-selection elements, IE 390 to 399. Riptide models packet selection and not flow selection, so an exporter running an Intermediate Flow Selection Process reports its flows at whatever rate its packet selection states, or at 1, with no signal that most of its flows were discarded before export. See issue 596.
A zero does not mean no such exporter exists. It means none has sent a template to this collector since it started. That distinction is the reason the counter exists at all: a survey of exporter source concluded the packet-selection family was unimplemented in practice, and softflowd was then found emitting it, with a 1:100 sampled exporter recorded as unsampled and its volume under-reported hundredfold (issue 598).
This counter is IPFIX-only. NetFlow v9 field types are a different numbering space, and a v9 type 390 is not IE 390.
Parser gauges: exporters and templates
Two gauges describe what a UDP parser is holding. They are easy to confuse, and until 0.7.0
sessionCount reported the wrong one of the two.
| Metric | Meaning |
|---|---|
parsers.<name>.sessionCount | exporters — one per (session, observation domain) pair |
parsers.<name>.templateCount | templates held across all exporters |
These three (with dispatchQueueDepth above) are registered while the parser runs and deregistered when it stops, so a stopped receiver publishes no series at all rather than a final or zero reading.
Alert on absence, not on a value: a rule like parsers_<name>_sessionCount == 0 goes stale instead of firing, because a stopped parser previously reported its last counts forever while a stopped dispatch queue read 0, which is indistinguishable from healthy.
NetFlow v5 sampling rate resolution
NetFlow v5 has no options table, so a v5 flow's sampling rate resolves from the packet header, then
the receiver's flow-sampling-interval-fallback, then an assumed 1. Which rung answered is metered
per packet (the rate lives in the header, so every record in a packet resolves identically) and
per receiver.
| Metric | Meaning |
|---|---|
parsers.<name>.samplingRate.header | packets whose rate came from the exporter's header |
parsers.<name>.samplingRate.fallback | packets that fell through to the configured rate |
parsers.<name>.samplingRate.assumed | packets with no rate anywhere, recorded as 1 |
assumed is not the same statement as an exporter reporting a rate of 1.
An exporter that states 1 has said it does not sample, and that lands under header.
assumed means nothing stated a rate at all, and 1 is what riptide wrote in the absence of one.
Each meter's leaf name is the value written to that flow's samplingProvenance column, so a meter and the rows it counted always agree.
The meters answer "is this happening now" without a query; the column answers it for any period, for every protocol, and per exporter:
SELECT exporterAddr, samplingProvenance, samplingInterval, count() AS flows
FROM flows
WHERE timestamp > now() - INTERVAL 1 HOUR
GROUP BY exporterAddr, samplingProvenance, samplingInterval
ORDER BY exporterAddr, flows DESC
One exporter appearing under two provenances is a rate that is not resolving consistently — firmware populating the sampling field on some export paths and not others, or a v9 sampler options table expiring between refreshes. See Where a rate came from for the full vocabulary.
These exist because the resolution is invisible in the data path: riptide records the rate without
applying it, so an exporter that starts or stops advertising changes no counter and raises no error.
A header rate that falls to zero means a fleet stopped advertising and is now being recorded at an
assumed 1 — or on the configured rate, which may not match. See
Sampling rate for the resolution order and settings.
What changed in 0.7.0. sessionCount used to report the template total, so it overstated by
however many templates each exporter announces. It now reports (session, observation domain) pairs.
Expect the value to drop on upgrade, by roughly the templates-per-exporter factor; the previous
quantity is still available, under the name that describes it — templateCount. This changes what a
metric means, not any configuration key.
What moves sessionCount is a new exporter appearing, or an exporter's last template expiring and
housekeeping reaping it. A steady-state re-announcement of a template the exporter has already sent
moves neither gauge: addTemplate replaces the entry under the same template id.
It is not a count of exporting processes. A session is keyed by remote address plus the local socket, and each observation domain within a session counts separately, so:
- one process announcing two observation domains counts 2
- one process sending to two receiver ports counts 2
- two processes behind one NAT address, sharing an observation domain, count 1
Only IPFIX and NetFlow v9 populate these gauges. NetFlow v5 and sFlow carry no templates, so both
gauges stay 0 for those receivers no matter how many exporters are sending — a 0 here is not an
ingest fault, and the drop-on-upgrade note above does not apply to them. On a multi receiver each
sub-protocol registers its own pair under its own name (<name>:netflow5, <name>:sflow, …), so
those pairs read 0 while the IPFIX and NetFlow v9 pairs report real values.
sessionCount is only eventually consistent with "holds at least one template": housekeeping expires
templates in one pass and reaps the emptied exporters in a second, so the gauge can transiently
include an exporter holding none. An alert on it has to tolerate that flap.
Template cardinality is the more useful of the two for capacity work — it is what drives the per-record cost of the parse path. For the drop and depth metrics, see Ingest loss counters above.
These gauges, and every other metric on this page, are published on the management port at
GET /metrics in Prometheus text format. See Metrics endpoint below.
Health endpoints & probes
Riptide serves two plain-HTTP health endpoints on a management port (default 8080) — no auth, no
TLS, cluster-internal. They're built on the JDK HTTP server, so the collector stays headless (no
application web server).
| Endpoint | Meaning |
|---|---|
GET /livez | Liveness — the receiver event loops are alive. Returns 200 while booting and once running; 503 only if a started receiver's socket has died. Never checks ClickHouse. |
GET /readyz | Readiness — all configured receivers are bound and listening (200), else 503. Zero configured receivers reports ready (see the contract notes below). |
GET /metrics | Metrics — the full metric registry in Prometheus text format. See below. |
Configure via riptide.management.*:
riptide.management.enabled=true # set false to disable the endpoints entirely
riptide.management.port=8080
riptide.management.bind-address=0.0.0.0
riptide.management.metrics-enabled=true # set false to serve probes but not /metrics
Readiness deliberately excludes ClickHouse.
Flows arrive as UDP push: a "not ready" collector does not stop the packets, it only moves the loss to another layer (a drained load balancer under externalTrafficPolicy: Local, or the wire).
When ClickHouse recovers, readiness convergence typically loses more flows than the bounded batching queue (riptide.clickhouse.batch.queue-capacity, 40,000 rows by default) absorbs.
At the measured ~11.8k rows/s the queue covers ~3.4 s, well under a probe period plus endpoint propagation.
And where Prometheus scrapes through the Service, "not ready" can remove the pod from the endpoints and take /metrics down with it.
That blinds the one signal that explains the outage, exactly when it fires.
A ClickHouse outage keeps the collector receiving.
Probes are for scheduling; saturation is for alerting: watch a sustained persister.batch.droppedRows or persister.batch.failedRows rate and persister.batch.queueDepth approaching the queue capacity. A non-zero persister.batch.deadLetterFailedRows rate is a separate signal: the refused rows are not being kept anywhere.
Readiness also deliberately tolerates zero configured receivers. The shipped configuration declares none, so failing readiness there would turn a fresh install into a pod that never becomes ready. A collector without receivers logs a startup WARN ("No receivers configured") and reports ready: misconfigured, not unhealthy.
Kubernetes probe mapping:
startupProbe: { httpGet: { path: /readyz, port: 8080 }, failureThreshold: 30, periodSeconds: 2 }
livenessProbe: { httpGet: { path: /livez, port: 8080 } }
readinessProbe: { httpGet: { path: /readyz, port: 8080 } }
The Compose stack uses /readyz as the service healthcheck (via the image's BusyBox wget).
The endpoints are served on virtual threads, capped by riptide.management.max-concurrent-requests (default 32).
Requests beyond the cap are answered 503 rather than queued, so a probe gets a fast answer instead of waiting behind a burst.
Metrics endpoint
GET /metrics renders the whole metric registry in Prometheus text exposition format 0.0.4.
curl -s http://localhost:8080/metrics
Registry names contain dots; Prometheus metric names may not.
Characters outside [a-zA-Z0-9_:] are replaced with _, so enrichment.optionInterfaces.consumed is scraped as enrichment_optionInterfaces_consumed.
Counters are not given the conventional _total suffix, so a name you find in the source is the name you search for in Grafana.
Type mapping:
| Registry type | Exposed as |
|---|---|
| Gauge (numeric) | gauge. Non-numeric gauges are skipped — they have no valid representation, and emitting one would break the entire scrape rather than one series. |
| Counter | counter |
| Meter | counter, plus _rate_1m and _rate_5m gauges carrying Dropwizard's own moving averages |
| Histogram | summary with p50/p95/p99 and _count |
| Timer | summary named <name>_seconds with p50/p95/p99 and _count. Durations are converted from nanoseconds to seconds, the unit Prometheus tooling assumes. |
The endpoint shares the probes' concurrency cap rather than having its own.
Rendering walks the whole registry, so it is the more expensive handler and has more reason to be bounded, not less.
A scrape that loses the race is shed with 503, which Prometheus records as a failed scrape.
Set riptide.management.metrics-enabled=false to serve probes without exposing metric names and values.
The two are separate settings because they have different exposure profiles: probes answer up/down, while metrics describe your exporters and throughput.
With it disabled the path is not registered at all, so a scrape gets 404.
jstack does not show virtual threads, so the management-http-* handlers are invisible to it and to top -H.
Their absence from a thread dump means the server is idle, not dead.
To see them, take a dump that includes virtual threads:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
Continuous profiling
Riptide can ship continuous profiles to a Pyroscope server. It is off by default and starting it is one variable:
riptide.profiling.enabled=true # RIPTIDE_PROFILING_ENABLED=true
That is the only setting riptide owns. Everything else is Pyroscope's own PYROSCOPE_* environment vocabulary, read by the agent rather than restated here, so an option added upstream works without riptide knowing about it:
RIPTIDE_PROFILING_ENABLED=true
PYROSCOPE_SERVER_ADDRESS=http://pyroscope.internal:4040
PYROSCOPE_APPLICATION_NAME=riptide
PYROSCOPE_PROFILER_EVENT=itimer # default; see the event table below
PYROSCOPE_LABELS=region=eu-west,role=edge
PYROSCOPE_UPLOAD_INTERVAL=10s
Set PYROSCOPE_SERVER_ADDRESS even though nothing forces you to. Omit it and the agent falls back to its own default of http://localhost:4040, where profiling starts cleanly, logs Continuous profiling started, and uploads every profile into nothing. No error is raised, because from the agent's point of view it was configured. The startup line reporting success is not evidence that a server received anything.
See Pyroscope's own documentation for the full list. Riptide overrides three things and no more: it enables the agent, because reaching that code is the decision to enable; it merges the labels below into whatever PYROSCOPE_LABELS set, so yours survive; and it supplies a stable application name if you did not choose one. On a label-name collision riptide's identity wins, because it describes what the collector actually is.
PYROSCOPE_AGENT_ENABLED is the one variable riptide overrides unconditionally. Setting it to false will not turn profiling off, because riptide.profiling.enabled is the switch this project documents and supports. Use that one.
If the agent fails to start, riptide says so and carries on. The agent catches its own start failures and reports them on standard error rather than through the collector's log, so riptide checks afterwards and logs an ERROR naming the application and event when nothing actually started. A green "Continuous profiling started" line therefore means it started; it does not mean profiles will be useful.
Profiles are labelled with your deployment identity — tenant, organisation, zone and system, from riptide.identity.*. That is what makes profiles filterable when several collectors report to one server, and it is the reason profiling is started in-process rather than as a -javaagent: the agent has no notion of a tenant or a zone.
What it costs when it is off
The agent is a dependency, so it ships in every artefact whether or not you enable it: about 5.5 MB of jar, of which roughly 2.3 MB is async-profiler's bundled native libraries. Nothing is loaded, no thread starts and no connection is opened unless the setting above is set.
What the profile measures, and which event to ask for
The default event is itimer, and itimer measures CPU time — async-profiler drives it with setitimer(ITIMER_PROF). It needs no perf_event_open, so out of the box nothing is refused and nothing falls back, on any deployment.
The events differ in what they can show you, and picking the wrong one is the easiest way to read a profile backwards:
PYROSCOPE_PROFILER_EVENT | measures | needs perf_event_open? |
|---|---|---|
itimer (default) | CPU time | no |
cpu | CPU time, with kernel stacks | yes |
wall | wall-clock — includes time blocked on IO and locks | no |
alloc, lock | allocation, contention | no |
cpu was expected to be refused under the shipped unit, and measurement says otherwise. On a real deployment running the shipped unit file (User=riptide, NoNewPrivileges=yes, ProtectSystem=strict, no capabilities) on Ubuntu 24.04 with perf_event_paranoid=4, both itimer and cpu started and produced correctly attributed samples: around 800 samples over 8 seconds with 99.9% on the intended method.
What that does not establish is which mechanism cpu used. async-profiler can fall back internally without saying so, and the sample output cannot distinguish a successful perf_event_open from a silent degrade. The practical answer is that both events give you a usable profile on a hardened unit; the mechanistic one is unverified.
If you are chasing time spent waiting rather than time spent computing, ask for wall. Blocked-on-IO and lock-wait frames dominate a wall-clock profile and are nearly absent from a CPU one, so neither itimer nor cpu will show you a stall.
Riptide cannot tell you which mode the process actually obtained. The agent's API exposes the event that was configured and nothing that reports a fallback, so the line logged at startup names what was requested and says so explicitly.
Enable native access on a future JDK
With profiling on, JDK 25 warns that System::load is a restricted method and that restricted methods will be blocked in a future release unless native access is enabled. Profiling works today and will stop working on a JDK that enforces this.
Only one of the two variables works everywhere, and JAVA_OPTS is the narrower one. The deb and rpm unit is the single case that expands $JAVA_OPTS, because its ExecStart names the variable. The container has an exec-form ENTRYPOINT and no shell, so it never sees it. Nix does not see it either: nix/package.nix builds the launcher with makeWrapper ... --add-flags "-jar ...", which emits exec "<java>" -jar <path> "$@" and references no environment variable, and nix/module.nix points ExecStart straight at that wrapper. Verified by building the wrapper and reading it: zero occurrences of JAVA_OPTS. So a flag set that way on Nix is discarded in silence.
JDK_JAVA_OPTIONS is read by the java launcher itself, so it works in all four cases:
# deb and rpm only, via /etc/riptide/riptide.env
JAVA_OPTS=--enable-native-access=ALL-UNNAMED
# container, Nix, plain `java -jar`, and also fine on the deb and rpm
JDK_JAVA_OPTIONS=--enable-native-access=ALL-UNNAMED
On Nix, put JDK_JAVA_OPTIONS in the file named by services.riptide.environmentFile. JAVA_OPTS there does nothing.
New deb and rpm installs ship this pairing commented out in /etc/riptide/riptide.env, directly beside the profiling toggle, so turning one on puts the other in front of you.
An upgrade does not: the file is packaged config|noreplace, so an already-edited copy is kept and the new block arrives as .dpkg-dist or .rpmnew for you to diff.
JAVA_OPTS is a single assignment and the last one wins, so carry every option on one line rather than assigning it twice:
JAVA_OPTS=-Xmx2g --enable-native-access=ALL-UNNAMED
The flag is deliberately absent from the default ExecStart, ENTRYPOINT and Nix wrapper: profiling is opt-in, and putting it there would grant native access to every deployment including the majority that never profile.
Be clear about what is granted when you do opt in. ALL-UNNAMED covers every class on the classpath, not only the agent, and for a Spring Boot fat jar there is no narrower target, since all of it is unnamed.
Confirm it landed, because both routes fail silently. The restricted-method warning disappearing from journalctl -u riptide is the signal; tr '\0' '\n' < /proc/<pid>/cmdline | grep enable-native-access shows whether the flag reached the process at all.
Measured, on one deployment, that the warning is profiling-only. On Ubuntu 24.04 with openjdk 25.0.4 and agent 2.9.1, a journal covering four service starts held three restricted-method warnings and three Profiling started lines (the agent's own token, distinct from riptide's Continuous profiling started), and the one start without profiling was clean.
Those are aggregate counts rather than a start-by-start pairing, so they are consistent with one warning per profiling start without demonstrating it.
Two things would falsify the claim: a start with profiling off that still warns, meaning something else loads a native library, or a start with profiling on that does not, which is what a JDK already denying native access would look like.
Applying the flag on that deployment took the count from three to zero with profiling still running and samples still reaching the server.
Profiling used to produce a second warning, which this flag never silenced. That one is now gone. Through agent 2.9.1 the agent also triggered sun.misc.Unsafe::arrayBaseOffset has been called by io.pyroscope.vendor.com.google.protobuf.UnsafeUtil$MemoryAccessor.
That is a terminally deprecated method rather than a restricted one, so --enable-native-access had no effect on it either way.
It came from a protobuf copy vendored inside the agent, which riptide does not control: 2.9.1 vendored protobuf 4.33.5, and the 2.9.2 that riptide now ships in pom.xml's pyroscope.version vendors 4.36.1, which no longer touches that class when it encodes a profile.
The call has not been deleted, so do not read this as the class being fixed. Force io.pyroscope.vendor.com.google.protobuf.UnsafeUtil to initialise under 2.9.2 and it still warns, attributed now to UnsafeUtil itself rather than to its MemoryAccessor: 4.36.1 probes arrayBaseOffset deliberately, to detect a JVM running in deny mode.
What changed is that nothing on the agent's own path initialises it.
Measured, with 2.9.1 as a control, on one JVM (openjdk 25.0.4): each version started the agent against a local server that accepted four uploaded profiles in both runs, so the encode path demonstrably ran either way.
The 2.9.1 run emitted the arrayBaseOffset warning; the 2.9.2 run emitted no line mentioning Unsafe at all, leaving only the restricted-method warning above.
That is a local probe rather than a deployment, so it does not rule out some other path that only a real collector exercises.
What would falsify it: any UnsafeUtil line in the journal of a collector running the shipped agent.
If you pin an older agent, --sun-misc-unsafe-memory-access=allow quiets the warning, subject to the same JAVA_OPTS and JDK_JAVA_OPTIONS distinction above, but it defers the problem rather than fixing it, and the deferral ends more abruptly than the one above: when the JDK drops the option, an unrecognised flag stops the JVM from starting at all rather than costing you a profile.
A stable application name
If PYROSCOPE_APPLICATION_NAME is unset, riptide uses riptide. Left to the agent it would generate javaspy.<random> afresh on every start, so each restart would appear as a new service nobody can search for. Set it explicitly if you run more than one collector against one server, or rely on the identity labels above to tell them apart.
Containers, and what to do when a profile looks wrong
The agent's native libraries are glibc-linked with no musl build among them, and the shipped image is Alpine. That turns out not to stop it: musl ignores symbol versioning, so they load, and the profiler starts on the shipped image for every event tested (itimer, cpu, wall, on amd64).
It also profiles correctly there. Measured on eclipse-temurin:25-alpine amd64: 801 samples over 8 seconds, 99.88% attributed to the intended method, against 99.75% on a glibc image doing the same work. Stack unwinding was the suspected musl failure and it does not appear.
The limit on that: a tight synthetic loop is the easiest case an unwinder ever sees, and riptide's real hot paths are Netty event loops, virtual threads and JIT-compiled code. This shows the profiler is not broken on musl. It does not prove every profile is accurate, so a container profile showing frames that cannot be real is still worth suspecting the unwinder for.
If it ever does look wrong, JFR is the fallback, and it needs two variables rather than one. JFR is a second profiler in the same jar. It uses no native library and no perf_event_open, so it behaves identically on musl and glibc:
PYROSCOPE_PROFILER_TYPE=JFR
PYROSCOPE_PROFILER_EVENT=cpu # required: JFR rejects the default itimer
Setting only PYROSCOPE_PROFILER_TYPE=JFR does not work. JFR refuses the default itimer event and refuses wall; it accepts cpu, alloc and lock. Riptide logs an ERROR and carries on without profiling if you get this wrong, so the failure is visible rather than silent.
What JFR costs you is fidelity. Its sampling is subject to safepoint bias, meaning samples land where the JVM can conveniently stop rather than exactly where time is spent, and its allocation and lock profiling are weaker than async-profiler's. For questions like "which method dominates a rebuild" it is entirely adequate.
Ports
| Port | Protocol | What |
|---|---|---|
9999/udp | NetFlow/IPFIX | default flow ingest (container EXPOSE; receivers are configurable) |
8080 | HTTP | management endpoints (/livez, /readyz, /metrics) |
8123 | HTTP | ClickHouse (the compose stack publishes it on loopback only; password from CLICKHOUSE_PASSWORD) |