Files
Phoebe Goldman ecc00cae40 Add reducer name to database log records (#3274)
# Description of Changes

Extends log records with a new field, `function`, which stores the name
of the reducer being executed when the log was produced. I have chosen
to name this field `function` rather than `reducer` because we will soon
be adding procedures, which are not reducers but will also be valid
values for this field.

While making this change, I noticed inconsistent values for injected
logs. Previously, we injected logs in three places, with different
values for the record fields:

1. `SystemLogger` (used when publishing and updating) set `filename:
Some("spacetimedb")` and `target: None`.
2. `log_reducer_error` (used for reducer error returns) set `filename:
None` and `target: Some(reducer)`, with `reducer` being the name of the
reducer.
3. `ModuleHost::inject_logs` (used for calls to nonexistent reducers and
calls with ill-typed arguments) set `filename: Some("external")` and
`target: None`.

With this change, I have decided that injected logs universally have
`filename: Some("__spacetimedb__")` and `target:
Some("__spacetimedb__")`. I have chosen the double-underscore convention
for reserved names to avoid confusion should a user name a source file
or reducer `spacetimedb`. I am not terribly attached to using
`spacetimedb` here, and would be happy to change to some other string,
like `internal` or `system`.

I have further decided that `log_reducer_error` and `inject_logs` both
have access to a sensible non-`None` value for `function` and so should
set it to the name of the reducer. The `target` field is not used for
this. Note that in cases where a client attempts to call a non-existent
reducer, the `function` field of the log record will be set to that
client-supplied non-existent reducer name.

# API and ABI breaking changes

Changes our log record format. This is at least backwards-compatible (as
in, newer consumers can read older logs) because the new field is
optional. I am unsure if it is forwards-compatible (as in, older
consumers reading newer logs) because I don't know if `serde_json` will
ignore unknown fields or will error.

# Expected complexity level and risk

1

# Testing

- [x] Manually ran `quickstart-chat` (modified to log messages and name
changes) and saw sensible output.
2025-09-23 16:01:26 +00:00

95 lines
2.8 KiB
Rust

use spacetimedb::{Identity, ReducerContext, Table, Timestamp};
#[spacetimedb::table(name = user, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: Option<String>,
online: bool,
}
#[spacetimedb::table(name = message, public)]
pub struct Message {
sender: Identity,
sent: Timestamp,
text: String,
}
fn validate_name(name: String) -> Result<String, String> {
if name.is_empty() {
Err("Names must not be empty".to_string())
} else {
Ok(name)
}
}
#[spacetimedb::reducer]
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
let name = validate_name(name)?;
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
log::info!("User {} sets name to {name}", ctx.sender);
ctx.db.user().identity().update(User {
name: Some(name),
..user
});
Ok(())
} else {
Err("Cannot set name for unknown user".to_string())
}
}
fn validate_message(text: String) -> Result<String, String> {
if text.is_empty() {
Err("Messages must not be empty".to_string())
} else {
Ok(text)
}
}
#[spacetimedb::reducer]
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
// Things to consider:
// - Rate-limit messages per-user.
// - Reject messages from unnamed user.
let text = validate_message(text)?;
log::info!("User {}: {text}", ctx.sender);
ctx.db.message().insert(Message {
sender: ctx.sender,
text,
sent: ctx.timestamp,
});
Ok(())
}
#[spacetimedb::reducer(init)]
// Called when the module is initially published
pub fn init(_ctx: &ReducerContext) {}
#[spacetimedb::reducer(client_connected)]
pub fn identity_connected(ctx: &ReducerContext) {
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
// If this is a returning user, i.e. we already have a `User` with this `Identity`,
// set `online: true`, but leave `name` and `identity` unchanged.
ctx.db.user().identity().update(User { online: true, ..user });
} else {
// If this is a new user, create a `User` row for the `Identity`,
// which is online, but hasn't set a name.
ctx.db.user().insert(User {
name: None,
identity: ctx.sender,
online: true,
});
}
}
#[spacetimedb::reducer(client_disconnected)]
pub fn identity_disconnected(ctx: &ReducerContext) {
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
ctx.db.user().identity().update(User { online: false, ..user });
} else {
// This branch should be unreachable,
// as it doesn't make sense for a client to disconnect without connecting first.
log::warn!("Disconnect event for unknown user with identity {:?}", ctx.sender);
}
}