Metrics, tracing and logs
Rivo FX records spans around effects, keeps counters, gauges and histograms per resource, and writes levelled logs that carry the current trace. This page covers tracing (eff:withSpan, Fx.span, Fx.trace.*), metrics (Fx.metrics.*, trackDuration, trackErrors), logging (Fx.log.*, Fx.logInfo and friends), the spans and metrics the library records by itself, and the rivo_fx HTTP endpoint that serves them to Prometheus. Use it to find out where time goes, what failed and how often.
Tracing
A span records a name, a start and end time (Unix ms), a status and attributes. The span an effect runs in is the parent of the spans it starts, including spans on fibers it forks. Fx.rpc carries the caller's span to the host as a W3C traceparent, so a client call and the server handler it reaches share one trace. Finished spans go to Fx.trace.on listeners, Fx.trace.stream(), and a ring buffer of the last 200 per resource (Fx.trace.recent()). Tracing is per resource and per side.
A finished span is a table:
| Field | Type | Meaning |
|---|---|---|
name |
string | span name |
traceId |
string | 32 lowercase hex characters |
spanId |
string | 16 lowercase hex characters |
parentSpanId |
string? | parent span, nil for a root span |
kind |
string | 'internal' (default), 'server', 'client', 'producer' or 'consumer' |
startTime, endTime |
integer | Unix ms |
status |
string | 'ok', 'error' or 'unset' |
message |
string? | on error: the error tag, the error as a string, or the first line of a defect (at most 256 characters, then ...) |
attributes |
table | your attributes, plus fx.error and fx.interrupted (below) |
events |
table? | { name, time, attributes } added with span:addEvent |
resource |
string | the resource that recorded it |
seq |
integer | order of finishing in this resource |
How an effect's outcome sets the status:
| Outcome | status |
Extra |
|---|---|---|
| success | 'ok' |
|
failure with a tagged error declared in this resource with Fx.TaggedError |
'error' |
message and attributes['fx.error'] are the tag |
| failure with a tagged error rebuilt from the wire whose tag this resource never declared | 'error' |
message is the tag, attributes['fx.error'] = 'error' |
| failure with any other expected error | 'error' |
message = tostring(err), no fx.error |
| defect | 'error' |
attributes['fx.error'] = 'defect' |
| interruption | 'unset' |
attributes['fx.interrupted'] = true |
eff:withSpan(name, opts)
eff:withSpan(name: string, opts?: {
attributes?: table<string, any>,
kind?: 'internal'|'server'|'client'|'producer'|'consumer',
parent?: Fx.Span|string, -- a span or a W3C traceparent string
root?: boolean, -- start a new trace
}): Fx.Effect<A, E>
- Parameters:
name, the span name;opts.attributes, copied onto the span;opts.kind(default'internal');opts.parent, a span or traceparent string to continue instead of the current span;opts.root = trueto start a new trace even inside another span. - Returns: an effect that runs
effinside a new span and has the same result. - Fails with: whatever
efffails with. - Notes: both sides. The span starts when the effect runs, not when
withSpanis called, so each run makes a new span. Aparentstring that does not parse starts a new trace instead of failing. When tracing is off,effruns unchanged. An interruption at any point either finds no span or finishes it; no span is left open.
local function deposit(src, amount)
return Bank.credit(src, amount)
:withSpan('bank.deposit', { attributes = { amount = amount, player = src } })
end
Fx.span(name, fn, opts)
Fx.span(name: string, fn: fun(...): Fx.Effect<A, E>, opts?: Fx.SpanOptions): fun(...): Fx.Effect<A, E>
- Parameters:
name, the span name;fn, a function that returns an effect;opts, as inwithSpan. - Returns: a function with the same arguments as
fn. Each call returnsfn(...)'s effect inside a span. - Fails with: whatever
fn's effect fails with. A raisingfnfails the effect. - Notes: both sides.
fnruns when the effect runs, not when the wrapper is called.
local deposit = Fx.span('bank.deposit', function(src, amount)
return Fx.annotateCurrentSpan('amount', amount):zipRight(Bank.credit(src, amount))
end)
Fx.runFork(deposit(source, 500))
Fx.currentSpan
Fx.currentSpan: Fx.Effect<Fx.Span|nil, never>
- Returns: an effect (a value, not a function) that succeeds with the span the fiber runs in, or
nil. - Fails with: never.
- Notes: both sides. Use it to add events or read ids.
local job = Fx.gen(function()
local span = Fx.yield(Fx.currentSpan)
if span then span:addEvent('cache miss', { key = 'garage:12' }) end
return Fx.yield(Garage.load(12))
end):withSpan('garage.load')
Fx.annotateCurrentSpan(key, value)
Fx.annotateCurrentSpan(key: string, value: any): Fx.Effect<nil, never>
- Parameters:
key, the attribute name;value, its value. - Returns: an effect that sets the attribute on the current span.
- Fails with: never.
- Notes: both sides. Does nothing outside a span.
Fx.rpc.register('shop:buy', { input = BuyInput }, function(src, input)
return Fx.annotateCurrentSpan('item', input.item)
:zipRight(Shop.buy(src, input.item, input.qty))
end)
span:setAttribute(key, value)
span:setAttribute(key: string, value: any): Fx.Span
- Parameters:
key,value. - Returns: the span, for chaining.
- Fails with: never.
- Notes: both sides. Plain function on a span table from
Fx.currentSpan,Fx.trace.startor a listener.
local span = Fx.trace.start('import vehicles')
span:setAttribute('rows', 120):setAttribute('source', 'csv')
Fx.trace.finish(span)
span:addEvent(name, attributes)
span:addEvent(name: string, attributes?: table): Fx.Span
- Parameters:
name, the event name;attributes, optional. - Returns: the span.
- Fails with: never.
- Notes: both sides. Each event gets the current Unix ms time. At most 32 events per span; later ones are ignored.
Fx.yield(Fx.currentSpan):addEvent('model loaded', { model = 'adder' })
span:traceparent()
span:traceparent(): string
- Returns: the W3C traceparent of the span:
00-<traceId>-<spanId>-01. - Fails with: never.
- Notes: both sides. Pass it to another system, or as
opts.parentto continue the trace.
local tp = Fx.yield(Fx.currentSpan):traceparent()
PerformHttpRequest(url, cb, 'POST', body, { ['traceparent'] = tp })
Fx.trace.on(fn)
Fx.trace.on(fn: fun(span: Fx.Span)): fun()
- Parameters:
fn, called with each finished span of this resource. - Returns: a function that removes the listener.
- Fails with: never. An error raised by
fnis reported as a defect and does not reach the span's effect. - Notes: both sides. Called synchronously when the span ends; keep it short.
local stop = Fx.trace.on(function(span)
if span.status == 'error' then
print(('span %s failed: %s'):format(span.name, span.message))
end
end)
Fx.trace.stream(opts)
Fx.trace.stream(opts?: { buffer?: integer }): Fx.Stream<Fx.Span>
- Parameters:
opts.buffer, how many spans may wait for a slow consumer (default 1024; past that the oldest are dropped). - Returns: a stream of spans finished after it starts. Never ends by itself.
- Fails with: never.
- Notes: both sides. See Streams.
-- server: ship slow spans to a log service in batches
Fx.runFork(Fx.trace.stream()
:filter(function(s) return s.endTime - s.startTime > 250 end)
:groupedWithin(50, 10000)
:runForEach(function(batch) return LogShipper.send(batch) end))
Fx.trace.recent(after)
Fx.trace.recent(after?: integer): Fx.Span[]
- Parameters:
after, optionalseq; only spans with a greaterseqare returned. - Returns: the most recent finished spans of this resource, oldest first, at most 200.
- Fails with: never.
- Notes: both sides. The buffer keeps the last 200 spans; older ones are overwritten.
local last = 0
RegisterCommand('slowspans', function()
for _, s in ipairs(Fx.trace.recent(last)) do
last = s.seq
if s.endTime - s.startTime > 100 then print(s.name, s.endTime - s.startTime) end
end
end, true)
Fx.trace.start(name, parent, opts)
Fx.trace.start(name: string, parent?: Fx.Span|{ traceId: string, spanId: string }, opts?: {
attributes?: table<string, any>,
kind?: 'internal'|'server'|'client'|'producer'|'consumer',
}): Fx.Span
- Parameters:
name;parent, a span or a parsed traceparent (fromFx.trace.parseTraceparent), ornilfor a new trace;opts.attributes(copied) andopts.kind. - Returns: a new open span with status
'unset'. - Fails with: never.
- Notes: both sides. A plain function, for code outside effects. End it with
Fx.trace.finish. It is not the current span of any fiber.parentmust be a table: a traceparent string is not parsed here and silently starts a new trace. Prefereff:withSpan, which starts and finishes for you.
AddEventHandler('onResourceStart', function(res)
local span = Fx.trace.start('resource start', nil, { attributes = { resource = res } })
-- ... plain Lua work
Fx.trace.finish(span)
end)
Fx.trace.finish(span, exit)
Fx.trace.finish(span: Fx.Span, exit?: Fx.Exit)
- Parameters:
span, fromFx.trace.start;exit, optional, sets the status from an effect's outcome (see the status table above). - Returns: nothing.
- Fails with: never.
- Notes: both sides. Without
exit, the status stays'unset'. Finishing a span twice does nothing the second time. The span then goes to listeners,Fx.trace.stream()andFx.trace.recent().
local span = Fx.trace.start('nightly cleanup')
Fx.runFork(Cleanup.run(), { onExit = function(exit) Fx.trace.finish(span, exit) end })
Fx.trace.parseTraceparent(s)
Fx.trace.parseTraceparent(s: string): { traceId: string, spanId: string, remote: true }|nil
- Parameters:
s, a W3C traceparent (00-<32 hex>-<16 hex>-<2 hex>, 55 characters). - Returns: the ids (lowercased) with
remote = true, ornilwhensis not a string, has the wrong length or shape, uses versionff, or has an all-zero trace or span id. - Fails with: never.
- Notes: both sides. The result works as
parentforFx.trace.start.
local parent = Fx.trace.parseTraceparent(headers['traceparent'])
local span = Fx.trace.start('webhook', parent, { kind = 'server' })
Fx.trace.traceparentOf(fiber)
Fx.trace.traceparentOf(fiber: Fx.Fiber): string|nil
- Parameters:
fiber. - Returns: the traceparent of the span the fiber runs in, or
nil. - Fails with: never.
- Notes: both sides.
Fx.rpcuses it to fill the request'stracefield. A fiber fromFx.runForkhas not run yet whenrunForkreturns, so this givesniluntil the fiber has entered its span.
local worker = Fx.runFork(Jobs.worker():withSpan('job worker'))
RegisterCommand('workertrace', function()
print(Fx.trace.traceparentOf(worker)) -- 00-<traceId>-<spanId>-01
end, true)
Fx.trace.failedSpanOf(cause)
Fx.trace.failedSpanOf(cause: Fx.Cause): Fx.Span|nil
- Parameters:
cause, a failure cause. - Returns: the innermost span that failed with this cause (or a cause inside it), or
nil. - Fails with: never.
- Notes: both sides. Used to link an unhandled fiber failure, reported after its spans have closed, to the span that raised it. Interruptions are not linked. A failure caught and raised again is a new cause.
Fx.runFork(Job.run(), { onExit = function(exit)
if exit._tag == 'Failure' then
local span = Fx.trace.failedSpanOf(exit.cause)
Fx.log.error('job failed', { span = span and span.name, traceId = span and span.traceId })
end
end })
Fx.trace.setEnabled(on)
Fx.trace.setEnabled(on: boolean)
Fx.trace.isEnabled(): boolean
- Parameters:
on,trueto record spans,falseto stop. - Returns: nothing;
isEnabled()returns the current setting. - Fails with: never.
- Notes: both sides, per resource. Overrides the
rivo_fx:tracingconvar. While off,withSpanandFx.spanrun the effect without a span (soFx.currentSpangivesnilin new work) and RPC calls and handlers record no spans.
-- turn tracing off in a hot resource during an event
Fx.trace.setEnabled(false)
Fx.trace.nowMs()
Fx.trace.nowMs(): integer
- Returns: Unix ms, from the platform clock anchored once to the wall clock (
os.time, orGetCloudTimeAsInton clients whereos.timeis not usable). - Fails with: never.
- Notes: both sides. This is the clock span times use.
local t0 = Fx.trace.nowMs()
Fx.trace.CurrentSpan
Fx.trace.CurrentSpan: Fx.Tag -- 'rivo_fx/CurrentSpan'
- Returns: the service tag that holds the current span in a fiber's context.
- Fails with: never.
- Notes: both sides.
withSpanprovides it. Providing it yourself makes a span fromFx.trace.startthe parent of spans inside an effect.
local span = Fx.trace.start('batch import')
Fx.runFork(Import.all():provideService(Fx.trace.CurrentSpan, span),
{ onExit = function(exit) Fx.trace.finish(span, exit) end })
Trace propagation over Fx.rpc
- Every
Fx.rpc.call/Fx.rpc.callClientruns in a spanrpc.client <name>(kindclient, attributesrpc.name,rpc.target, andplayerforcallClient). It is a child of the caller's current span. - The request carries that span's traceparent in its
tracefield. - The host runs the handler in a span
rpc.server <name>(kindserver, attributesrpc.name,rpc.caller, andplayeron the server) whose parent is the caller's span. Atracethat is missing, not a string, longer than 64 characters or malformed starts a new trace; the request is still answered. - The Lua and JS implementations use the same field, so traces join across languages. See Net events and RPC.
-- client: the 'open bank' span, the rpc.client span and the server's
-- rpc.server span all share one traceId
Fx.runFork(Fx.rpc.call('bank:balance', {}):withSpan('open bank'))
Convar rivo_fx:tracing
set rivo_fx:tracing off
off(exactly) stops span recording in every resource that loads Rivo FX on that side; any other value, or unset, leaves it on.- Read once, when the resource starts.
Fx.trace.setEnabledoverrides it at run time. - Clients only see replicated convars, so use
setr rivo_fx:tracing offto turn it off on clients too.
Metrics
Metrics live per resource and per side. A metric name may be defined more than once in a resource: the same kind returns the existing metric (its first options are kept), a different kind raises.
Names must match ^[a-zA-Z_:][a-zA-Z0-9_:]*$; label names must match ^[a-zA-Z_][a-zA-Z0-9_]*$ and label values are turned into strings. Each distinct label set is one series.
Series limit. A metric holds at most 1000 series. Updates that would create series number 1001 or later are dropped, and one warning is logged for that metric. This keeps a label a client controls (a player id, a payload field) from growing memory without bound. Avoid such labels anyway.
Metric methods do not check the metric's kind; call the ones listed for it.
Fx.metrics.counter(name, opts)
Fx.metrics.counter(name: string, opts?: { help?: string }): Fx.Counter
- Parameters:
name;opts.help, the description shown in Prometheus. - Returns: a counter: a value that only goes up. Methods:
inc,value,update. - Fails with: never; raises on an invalid name or when
nameis already another kind. - Notes: both sides.
local Deposits = Fx.metrics.counter('bank_deposits_total', { help = 'Deposits made' })
Deposits:inc(1, { account = 'main' })
Fx.metrics.gauge(name, opts)
Fx.metrics.gauge(name: string, opts?: {
help?: string,
collect?: fun(): number | { { labels: table, value: number } | { [1]: table, [2]: number } },
}): Fx.Gauge
- Parameters:
name;opts.help;opts.collect, a function that computes the gauge each time metrics are read, returning a number (one series without labels) or a list of{ labels, value }(or{ labels = ..., value = ... }) entries. - Returns: a gauge: a value that goes up and down. Methods:
set,add,value,update. - Fails with: never; raises on an invalid name or kind clash.
- Notes: both sides. With
collect,set/addvalues are not reported andvalue()returns 0; onlycollect's result appears insnapshot(). Entries whose value is not a number are skipped. A raisingcollectis reported as a defect and the gauge reports no series.
Fx.metrics.gauge('garage_vehicles_out', {
help = 'Vehicles spawned from garages, by garage',
collect = function()
local out = {}
for name, g in pairs(Garages) do out[#out + 1] = { labels = { garage = name }, value = g.out } end
return out
end,
})
Fx.metrics.histogram(name, opts)
Fx.metrics.histogram(name: string, opts?: { help?: string, buckets?: number[] }): Fx.Histogram
- Parameters:
name;opts.help;opts.buckets, ascending upper bounds (default{ 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000 }). - Returns: a histogram: counts of observations per bucket, plus sum and count. Methods:
observe,update. - Fails with: never; raises on an invalid name, kind clash, or buckets that do not ascend.
- Notes: both sides. An observation
vcounts in the first bucket withv <= bound, or above every bucket. The default buckets suit milliseconds.
local Latency = Fx.metrics.histogram('bank_deposit_ms', { help = 'Deposit time', buckets = { 5, 25, 100, 500 } })
counter:inc(n, labels)
counter:inc(n?: number, labels?: table<string, any>)
- Parameters:
n, how much to add (default 1);labels, the series. - Returns: nothing.
- Fails with: never; raises when
nis negative or a label name is invalid. - Notes: both sides. Plain function, runs at once.
Deposits:inc() -- no labels
Deposits:inc(2, { account = 'savings' })
gauge:set(v, labels)
gauge:set(v: number, labels?: table<string, any>)
- Parameters:
v, the value;labels, the series. - Returns: nothing.
- Fails with: never; raises when
vis not a number. - Notes: both sides.
local Online = Fx.metrics.gauge('players_online')
AddEventHandler('playerJoining', function() Online:set(#GetPlayers()) end)
gauge:add(n, labels)
gauge:add(n: number, labels?: table<string, any>)
- Parameters:
n, may be negative;labels. - Returns: nothing.
- Fails with: never.
- Notes: both sides.
local InQueue = Fx.metrics.gauge('queue_players')
InQueue:add(1) -- joined the queue
InQueue:add(-1) -- left it
histogram:observe(v, labels)
histogram:observe(v: number, labels?: table<string, any>)
- Parameters:
v, one observation;labels. - Returns: nothing.
- Fails with: never.
- Notes: both sides. For timing effects use
eff:trackDuration.
local Payouts = Fx.metrics.histogram('job_payout', { buckets = { 100, 500, 1000, 5000 } })
Payouts:observe(750, { job = 'trucker' })
metric:value(labels)
counter:value(labels?: table<string, any>): number
gauge:value(labels?: table<string, any>): number
- Parameters:
labels, the series. - Returns: the current value of a counter or gauge series, 0 when it was never set.
- Fails with: never.
- Notes: both sides. Returns 0 for histograms and for gauges with
collect; read those withFx.metrics.snapshot().
print(Deposits:value({ account = 'main' }))
metric:update(v, labels)
counter:update(n?: number, labels?): Fx.Effect<nil, never>
gauge:update(v: number, labels?): Fx.Effect<nil, never>
histogram:update(v: number, labels?): Fx.Effect<nil, never>
- Parameters: as
inc(counter),set(gauge) orobserve(histogram). - Returns: an effect that does the update when it runs.
- Fails with: never (invalid arguments raise when it runs, which is a defect).
- Notes: both sides. For use inside pipelines.
local Sold = Fx.metrics.counter('shop_items_sold_total')
local buy = Shop.buy(src, item):tap(function() return Sold:update(1, { item = item }) end)
eff:trackDuration(histogram, labels)
eff:trackDuration(histogram: Fx.Histogram, labels?: table<string, any>): Fx.Effect<A, E>
- Parameters:
histogram;labels, the series. - Returns: an effect with the same result that observes how long
efftook, in ms. - Fails with: whatever
efffails with. - Notes: both sides. Observes every outcome: success, failure and interruption. Time comes from the platform clock.
local Latency = Fx.metrics.histogram('bank_deposit_ms', { buckets = { 5, 25, 100, 500 } })
local function deposit(src, amount)
return Bank.credit(src, amount):trackDuration(Latency, { account = 'main' })
end
eff:trackErrors(counter, labels)
eff:trackErrors(counter: Fx.Counter, labels?: table<string, any>): Fx.Effect<A, E>
- Parameters:
counter;labels, extra labels added to every count. - Returns: an effect with the same result that adds 1 to
countereach timeefffails. - Fails with: whatever
efffails with. - Notes: both sides. Interruptions are not counted. The
errorlabel is the_tagof an error declared in this resource withFx.TaggedError;'error'for any other tagged error (one rebuilt from another side, whose tag a client could make up);'defect'for defects;'unknown'for other failures. A label you pass namederrorreplaces it.
local Failures = Fx.metrics.counter('bank_failures_total', { help = 'Failed bank operations' })
local function withdraw(src, amount)
return Bank.withdraw(src, amount):trackErrors(Failures, { op = 'withdraw' })
end
-- after a NotEnoughMoney failure:
-- bank_failures_total{error="NotEnoughMoney",op="withdraw"} 1
Fx.metrics.snapshot()
Fx.metrics.snapshot(): {
name: string,
kind: 'counter'|'gauge'|'histogram',
help: string,
buckets?: number[],
series: ({ labels: table<string, string>, value: number }
| { labels: table<string, string>, counts: integer[], sum: number, count: integer })[],
}[]
- Returns: every metric of this resource, in definition order, with copies of its series. Counter and gauge series have
value. Histogram series havecountsper bucket (not cumulative; the last entry counts observations above every bucket),sumandcount. - Fails with: never.
- Notes: both sides. Runs every gauge's
collect. Includes the built-in metrics.
for _, m in ipairs(Fx.metrics.snapshot()) do
if m.name == 'bank_deposit_ms' then
for _, s in ipairs(m.series) do print(s.labels.account, s.count, s.sum / s.count) end
end
end
Fx.metrics.prometheus(extraLabels, snapshot)
Fx.metrics.prometheus(extraLabels?: table<string, string>, snapshot?: Fx.MetricSnapshot[]): string
- Parameters:
extraLabels, added to every series that does not set them itself (for example{ resource = 'bank' });snapshot, what to render (defaultFx.metrics.snapshot()). - Returns: the Prometheus text format (version 0.0.4), ending in a newline.
- Fails with: never.
- Notes: both sides.
# HELPis written only whenhelpis set. Labels are sorted by name; a histogram'slecomes last. Histograms are rendered as cumulative_bucketlines plus+Inf,_sumand_count. NaN and infinities are rendered asNaN,+Infand-Inf. On the server you normally scrape the HTTP endpoint instead.
print(Fx.metrics.prometheus({ resource = GetCurrentResourceName() }))
-- # HELP bank_deposits_total Deposits made
-- # TYPE bank_deposits_total counter
-- bank_deposits_total{account="main",resource="bank"} 3
Logging
Fx.log writes levelled, structured log lines. The minimum level comes from the convar rivo_fx:logLevel: debug, info (default), warn, error or none, case-insensitive; an unknown value means info. It is read when the resource first uses Fx.log. On clients, use setr so the convar replicates. Each line is printed as [LEVEL] message key=value ... with colour codes, fields sorted by key; tables are inspected.
Error records reach the /fxdev devtools' Failures view whatever the level and sink.
Fx.log.info(msg, fields)
Fx.log.debug(msg: string, fields?: table)
Fx.log.info(msg: string, fields?: table)
Fx.log.warn(msg: string, fields?: table)
Fx.log.error(msg: string, fields?: table)
- Parameters:
msg, the message;fields, optional key/value pairs. - Returns: nothing.
- Fails with: never.
- Notes: both sides. Plain functions that write at once. Records below the level are dropped. Inside effects prefer
Fx.logInfoand friends, which add the fiber and trace ids.
Fx.log.info('garage opened', { src = source, garage = 'pillbox' })
-- [INFO] garage opened garage=pillbox src=3
Fx.log.error('db down', { query = 'SELECT 1' })
Fx.log.setLevel(level)
Fx.log.setLevel(level: 'debug'|'info'|'warn'|'error'|'none')
Fx.log.getLevel(): string
- Parameters:
level, case-insensitive. - Returns: nothing;
getLevel()returns the current level. - Fails with: never; raises on an unknown level.
- Notes: both sides, per resource. Overrides the convar.
RegisterCommand('bankdebug', function() Fx.log.setLevel('debug') end, true)
Fx.log.setSink(fn)
Fx.log.setSink(fn: (fun(level: string, msg: string, fields?: table))|nil)
- Parameters:
fn, receives each record at or above the level instead of printing it;nilrestores printing. - Returns: nothing.
- Fails with: never.
- Notes: both sides, per resource. Also receives the library's own logs (rejected payloads, failed handlers).
Fx.log.setSink(function(level, msg, fields)
if level == 'error' then exports.discord_logs:send(msg, json.encode(fields or {})) end
print(('[%s] %s'):format(level, msg))
end)
Fx.logInfo(msg, fields)
Fx.logDebug(msg: string, fields?: table): Fx.Effect<nil, never>
Fx.logInfo(msg: string, fields?: table): Fx.Effect<nil, never>
Fx.logWarn(msg: string, fields?: table): Fx.Effect<nil, never>
Fx.logError(msg: string, fields?: table): Fx.Effect<nil, never>
- Parameters: as
Fx.log.*. - Returns: an effect that writes the record when it runs.
- Fails with: never.
- Notes: both sides. Adds
fiber(the fiber id) and, inside a span,traceIdandspanId, so a log line leads to its trace. Yourfieldsare merged on top.
Fx.rpc.register('garage:take', { input = TakeInput }, function(src, input)
return Fx.gen(function()
Fx.yield(Fx.logInfo('taking vehicle', { src = src, plate = input.plate }))
return Fx.yield(Garage.take(src, input.plate))
end)
end)
-- [INFO] taking vehicle fiber=12 plate=RIVO001 spanId=... src=3 traceId=...
Built-in spans and metrics
Spans:
| Span | Kind | Where | Attributes |
|---|---|---|---|
rpc.client <name> |
client |
caller of Fx.rpc.call / callClient |
rpc.name, rpc.target, player (callClient) |
rpc.server <name> |
server |
RPC host, child of the caller's span | rpc.name, rpc.caller, player (server) |
net <name> |
server |
Fx.net.handle, only when the handler returns an effect (both sides) |
net.event, player (server) |
Metrics. Each is defined when its module is first used in the resource:
| Metric | Kind | Labels | Meaning |
|---|---|---|---|
rivo_fx_rpc_handler_ms |
histogram | name, outcome |
RPC handler time on the host |
rivo_fx_rpc_call_ms |
histogram | name, outcome |
RPC round trip seen by the caller |
rivo_fx_net_events_total |
counter | name |
net events that passed their checks (Fx.net.handle, Fx.net.stream) |
rivo_fx_security_events_total |
counter | kind |
rejected client input (security events) |
rivo_fx_fibers |
gauge | root fibers alive in this resource | |
rivo_fx_timers |
gauge | timers waiting in the fiber runner |
outcome is ok, interrupted, the error tag when this resource declared it (the Rpc* tags count), error for any other tagged error, or defect.
HTTP endpoint
The rivo_fx resource serves the metrics and recent spans of every server-side resource that uses Rivo FX, over the server's HTTP port:
| Request | Response |
|---|---|
GET /rivo_fx/metrics |
Prometheus text format (text/plain; version=0.0.4). Every resource's metrics merged, each series labelled resource="<name>" unless it sets resource itself. |
GET /rivo_fx/spans?limit=N |
JSON array of recent spans across resources, newest first. limit defaults to 100, at most 1000. Each resource contributes its last 200. |
- Off until the server convar
rivo_fx:metricsTokenis set; until then every request gets404. Set it withset, notsetr, so it never reaches clients. It is read on each request. - Requests must send
Authorization: Bearer <token>; otherwise401withWWW-Authenticate: Bearer. The comparison is constant time. - Methods other than
GETget405; other paths404; an internal error500. - When two resources define a metric with different kinds (or different histogram buckets), the first resource's definition is kept and the other's series are skipped.
- Client metrics and spans are not served here; see them in
/fxdev. - Span JSON fields:
seq,name,traceId,spanId,parentSpanId,kind,startTime,endTime,status,message,attributes,events,resource,side. Attribute values are reduced to plain data (functions become strings, depth and size are capped).
# server.cfg
ensure rivo_fx
set rivo_fx:metricsToken "a-long-random-string" # set, not setr: keep it off clients
# prometheus.yml
scrape_configs:
- job_name: fivem
metrics_path: /rivo_fx/metrics
authorization: { credentials: a-long-random-string }
static_configs: [{ targets: ['your-server:30120'] }]
curl -H 'Authorization: Bearer a-long-random-string' 'http://your-server:30120/rivo_fx/spans?limit=20'
Convars
| Convar | Values | Default | Read |
|---|---|---|---|
rivo_fx:tracing |
off to stop recording spans |
on | at resource start; Fx.trace.setEnabled overrides |
rivo_fx:logLevel |
debug, info, warn, error, none |
info |
on first use of Fx.log; Fx.log.setLevel overrides |
rivo_fx:metricsToken |
bearer token for the HTTP endpoint (server, set only) |
unset: endpoint off | on every request |