Skip to content

Logging

rust-samp provides three layers of logging, each one usable on its own:

  1. Turnkey — a single call (samp::enable_logger!()) installs a complete pipeline: per-plugin file under logs/, size-based rotation, automatic prefix derived from the plugin's name, a banner at startup and a runtime-adjustable level. Use this unless you have a specific reason not to.
  2. Customizablesamp::enable_logger_with!(LoggerConfig::new(...)) keeps the same pipeline but lets you tweak every knob: directory, filename, prefix, level, banner, format templates, rotation policy.
  3. DIYsamp::plugin::logger() returns a bare fern::Dispatch already chained to the server's log sink. Build whatever you want on top.

The first two layers route through the log crate's facade. Once installed, plain log::info! / log::warn! / log::error! calls in your plugin go through the configured pipeline.

Heads-up. Only one global logger can be installed per process. Combining layers 1/2 with samp::plugin::logger() is a no-op for the second installation. Choose one path or hand-build it.

Quick start

use samp::prelude::*;
use samp::{SampPlugin, initialize_plugin};

#[derive(SampPlugin, Default)]
struct MyPlugin;

impl SampPlugin for MyPlugin {
    fn on_load(&mut self) {
        let _ = samp::enable_logger!();
        log::info!("plugin loaded");
    }
}

initialize_plugin!(type: MyPlugin, natives: []);

That single enable_logger!() call:

  • Creates logs/ next to the server executable.
  • Opens logs/my-plugin.log (filename derived from CARGO_PKG_NAME).
  • Prepares logs/archive/ for rotated files (created lazily on the first rotation).
  • Prints a 5-line banner with the plugin name, version, authors and repository — read from CARGO_PKG_* at compile time.
  • Routes every subsequent log::info! / log::warn! / log::error! to both the server console (with [my-plugin] prefix) and the file (with timestamp + level).
  • Drops anything below Info by default — adjust via samp::logger::set_level(LevelFilter::Debug) at runtime.

What gets written where

For a plugin whose CARGO_PKG_NAME = "my-plugin":

Server console / server_log.txt

[20:14:32] Loading plugin: my-plugin.so
[20:14:32]
[20:14:32]   | my-plugin 1.0.0
[20:14:32]   |-------------------------------
[20:14:32]   | Author: Some Author
[20:14:32]   | Repository: https://github.com/some-author/my-plugin
[20:14:32]
[20:14:32]   Loaded.
[20:14:35] [my-plugin] player 1 connected
[20:14:40] [my-plugin] vehicle 42 spawned

The [20:14:32] timestamp comes from the server itself; the SDK only contributes the [my-plugin] prefix + message.

logs/my-plugin.log

[2026-06-08 20:14:35] [INFO] player 1 connected
[2026-06-08 20:14:40] [INFO] vehicle 42 spawned
[2026-06-08 20:14:42] [WARN] connection retry on slot 3

The file has its own timestamp and level — it does not include the plugin prefix because every line in it already belongs to this plugin.

Customizing the pipeline

enable_logger_with! accepts a LoggerConfig and returns the same Result. Every method on LoggerConfig is a fluent builder:

use std::time::Duration;
use samp::logger::{LoggerConfig, BannerMode};

let _ = samp::enable_logger_with!(
    LoggerConfig::new(env!("CARGO_PKG_NAME"))
        .directory("logs/my-plugin")          // → logs/my-plugin/my-plugin.log
        .filename("audit.log")                 // → logs/my-plugin/audit.log
        .prefix("[Audit]")                     // override console prefix
        .level(log::LevelFilter::Debug)        // verbose by default
        .also_to_server(false)                 // file only, silent in console
        .no_banner()                           // suppress startup banner
        .rotation_size_mb(100)                 // rotate at 100 MB
        .rotation_keep(10)                     // keep last 10 archives
        .file_format("{timestamp} | {level:>5} | {message}")
        .server_format("({prefix}) [{level}] {message}")
);

The macro still captures the caller's CARGO_PKG_* for the banner, which is why it can be a macro and not just a function call.

Format templates

file_format and server_format accept template strings with the following placeholders:

Placeholder Value
{timestamp} YYYY-MM-DD HH:MM:SS (local time, UTC fallback)
{level} ERROR, WARN, INFO, DEBUG, TRACE
{message} The formatted args from log::info!(...)
{prefix} Only available in server_format (the per-plugin tag)

Defaults:

  • file_format: "[{timestamp}] [{level}] {message}"
  • server_format: "{prefix} {message}"

Unknown placeholders pass through verbatim so typos are visible.

Alignment and padding

Each placeholder accepts an optional alignment + width spec, mirroring Rust's own format syntax:

Spec Effect
:<5 Left-aligned, padded to width 5
:>5 Right-aligned, padded to width 5
:^6 Centred, padded to width 6

Example template:

.file_format("{timestamp} | {level:>5} | {message}")

Produces:

2026-06-08 12:30:45 |  INFO | player connected
2026-06-08 12:30:46 |  WARN | retry scheduled
2026-06-08 12:30:47 | ERROR | timeout

Padding never truncates — a value longer than the requested width is emitted in full.

By default, install prints a 5-line banner at Info level introspecting the caller's manifest. Three modes are available:

use samp::logger::{LoggerConfig, BannerMode};

// 1. Default (the standard banner)
LoggerConfig::new(env!("CARGO_PKG_NAME"))
    .banner(BannerMode::Default)            // implicit default

// 2. Off
LoggerConfig::new(env!("CARGO_PKG_NAME"))
    .no_banner()                            // shortcut for BannerMode::Off

// 3. Custom — closure receives the manifest metadata
LoggerConfig::new(env!("CARGO_PKG_NAME"))
    .banner_with(|meta| vec![
        String::new(),
        format!("=== {} v{} ===", meta.name, meta.version),
        format!("Author: {}", meta.authors),
        format!("Repository: {}", meta.repository),
        format!("Compiled: {}", env!("CARGO_PKG_VERSION")),
        String::new(),
    ])

Custom banner lines are emitted at Info level through the same pipeline as everything else — they appear in both the server console (with prefix) and the file (with timestamp).

Rotation

Rotation kicks in when the active file exceeds rotation_size_mb (default 50 MB). The active file is renamed into {directory}/archive/{filename}.{N} and a fresh active file is opened.

The SDK ships two rotation strategies, controlled by rotation_keep:

Append-style (default)

// equivalent to: LoggerConfig::new(...).rotation_no_cleanup()
LoggerConfig::new(env!("CARGO_PKG_NAME"))

Each rotation picks the next free index. Existing archives are never deleted by the SDK; the dev decides what to do with old files (rotate externally via logrotate, manual cleanup, archive uploader, etc).

Initial scan at install time finds the highest existing {filename}.{N} and starts from N + 1, so indices keep growing across restarts without ever being reused.

logs/
├── my-plugin.log          ← active
└── archive/
    ├── my-plugin.log.1    ← oldest
    ├── my-plugin.log.2
    ├── my-plugin.log.3
    └── my-plugin.log.4    ← most recent archive

Shift-style (opt-in)

LoggerConfig::new(env!("CARGO_PKG_NAME"))
    .rotation_keep(5)       // keep last 5 archives, delete the rest

.log.5 is deleted, every other archive shifts down (.4 → .5, .3 → .4, …), and the active file becomes .log.1. Disk footprint is bounded to (keep + 1) * rotation_size_mb.

logs/
├── my-plugin.log          ← active (just rotated, empty)
└── archive/
    ├── my-plugin.log.1    ← most recent archive (was active)
    ├── my-plugin.log.2
    ├── my-plugin.log.3
    ├── my-plugin.log.4
    └── my-plugin.log.5    ← oldest retained (next rotation deletes it)

Disabling rotation entirely

LoggerConfig::new(env!("CARGO_PKG_NAME"))
    .no_rotation()

The active file grows indefinitely. Useful when an external rotator takes over (e.g. logrotate on a Linux server).

Compressing archives (feature compression)

Rotated archives can be gzip-compressed on the fly. Enable the compression Cargo feature and opt in with the builder:

[dependencies]
samp = { package = "rust-samp", version = "3", features = ["compression"] }
LoggerConfig::new(env!("CARGO_PKG_NAME"))
    .rotation_size_mb(50)
    .compress_archives(true)

Each rotation then produces my-plugin.log.1.gz instead of my-plugin.log.1 and removes the uncompressed file. Works with both rotation strategies, and the next-archive scan recognizes .gz variants so an index is never reused across restarts. The feature pulls in flate2 with the pure-Rust backend; plugins that do not enable it pay no extra dependency cost.

Adjusting the level at runtime

use log::LevelFilter;

samp::logger::set_level(LevelFilter::Debug);
let current: LevelFilter = samp::logger::level();

A common pattern is to expose a Pawn native that maps an integer coming from the gamemode into a LevelFilter:

#[native(name = "MyPlugin_SetLogLevel")]
fn set_log_level(_amx: &Amx, level: i32) -> bool {
    let target = match level {
        0 => log::LevelFilter::Off,
        1 => log::LevelFilter::Error,
        2 => log::LevelFilter::Warn,
        3 => log::LevelFilter::Info,
        4 => log::LevelFilter::Debug,
        _ => log::LevelFilter::Trace,
    };
    samp::logger::set_level(target);
    true
}

Runtime overrides from the environment

LoggerConfig::from_env() applies overrides from environment variables, so a server operator can change logging behaviour without recompiling the plugin:

let _ = samp::enable_logger_with!(
    LoggerConfig::new(env!("CARGO_PKG_NAME"))
        .from_env()
);

The variable prefix is derived from the crate name, uppercased with every non-alphanumeric character replaced by _ (so streamer-rsSTREAMER_RS_LOG_*). Recognized keys:

Variable ({PREFIX}_LOG_…) Effect
LEVEL Level filter (off/error/warn/info/debug/trace).
DIR Log directory.
FILE Active log filename.
ROTATION_MB Size threshold in MB; 0 disables rotation.
ROTATION_KEEP Number of archives to keep (shift-style).
NO_ROTATION Truthy disables rotation.
NO_BANNER Truthy suppresses the banner.
SERVER Truthy/falsy toggles forwarding to the server log.
COMPRESS Truthy enables gzip (only with the compression feature).

Missing variables leave the builder value untouched; invalid values are reported to the server console and the previous value is kept. from_env is safe to call before the runtime is initialized.

External sinks

add_sink forwards every accepted log record to a destination of the plugin author's choosing — Sentry, an OTLP collector, an in-house HTTP endpoint, anything. Implement the Sink trait and register an instance:

use samp::logger::{Sink, SinkRecord};

struct MySink { /* channel, client handle, ... */ }

impl Sink for MySink {
    fn emit(&self, record: &SinkRecord<'_>) {
        // Forward `record` to the external system. Keep this cheap and
        // non-blocking — push onto a channel drained by a worker thread.
    }
}

let _ = samp::enable_logger_with!(
    LoggerConfig::new(env!("CARGO_PKG_NAME"))
        .add_sink(Box::new(MySink::new()))
);

No telemetry is built into the SDK

The SDK ships zero add_sink calls of its own and adds no sentry / opentelemetry dependency. A sink becomes active only through an explicit add_sink(...) in the plugin's own source — server operators can audit this by grepping for add_sink(. There is no hidden flag, environment override, or default destination.

A complete, working Sentry integration (with the backpressure pattern: a sync_channel between the logger lock and a dedicated drainer thread) lives in examples/sink-demo/. The DSN is read from an environment variable, never hardcoded.

Flushing

samp::logger::flush() flushes the active log file directly through the live logger. Going through log::logger().flush() does not guarantee a sync of the SDK's own file handle; this does. It is a safe no-op when no logger has been installed — meant for panic hooks and custom shutdown paths:

std::panic::set_hook(Box::new(|info| {
    log::error!("panic: {info}");
    samp::logger::flush();
}));

Layer 3: DIY with fern

When the turnkey pipeline does not fit (e.g. you need JSON output, structured fields, an external log aggregator, or a non-standard destination), fall back to the existing helper:

initialize_plugin!(
    natives: [],
    {
        // Server dispatch (SA-MP logprintf / ICore::logLnU8).
        let samp_logger = samp::plugin::logger()
            .level(log::LevelFilter::Info);

        // File dispatch with a fully custom format.
        let log_file = fern::log_file("custom.log")
            .expect("failed to open log file");

        let file_logger = fern::Dispatch::new()
            .level(log::LevelFilter::Trace)
            .chain(log_file);

        let _ = fern::Dispatch::new()
            .format(|callback, message, record| {
                callback.finish(format_args!(
                    "[MyPlugin][{}]: {}",
                    record.level(),
                    message
                ));
            })
            .chain(samp_logger)
            .chain(file_logger)
            .apply();

        return MyPlugin::default();
    }
);

This is the same API documented in earlier releases — it has not been removed. It coexists with the turnkey logger but the two are mutually exclusive (only one global logger lives in the process).

Log levels

Level Suggested use
error! Errors that affect plugin behavior.
warn! Unexpected but non-critical situations.
info! Important lifecycle events (load, connections, …).
debug! Information useful while developing.
trace! Detailed internal traces.

Where things happen

log::info!("hello")
┌────────────────────┐
│ samp::logger       │  (only when enable_logger! ran)
│   - format         │
│   - level filter   │
└──────┬──────┬──────┘
       │      │
       │      └────────────► logs/{plugin}.log
       │                     [{timestamp}] [{level}] {message}
Runtime::log(...)
       ├── SA-MP                   → logprintf("[plugin] message")
       └── native Open Multiplayer → ICore::logLnU8("[plugin] message")

Common pitfalls

Logs before on_load are dropped

The server's log sink only becomes available when Load() runs on SA-MP, or when ICore* is delivered in onLoad(ICore*) on native Open Multiplayer. Any log::* call made earlier — for example inside the constructor block — falls back to eprintln!.

Put initialization logs inside on_load, never in the constructor block.

also_to_server(false) does not drop the prefix from the file

The prefix is only used for the server console sink. The file uses file_format, which has no prefix placeholder by default. Setting also_to_server(false) removes the console destination entirely; the file format keeps producing [timestamp] [LEVEL] message lines.

The banner is emitted at Info level through the configured pipeline — which means it goes to both the server console and the file. If a clean file is important (e.g. for log shipping or analysis), use .no_banner() or move the banner to a separate println! outside the logger.

enable_logger! returns a Result

let _ = samp::enable_logger!();

The most common error is InstallError::AlreadyInstalled if a previous enable_logger! (or samp::plugin::logger().apply()) already ran in this process. The other variant, InstallError::Io, wraps a std::io::Error from creating the directory or opening the file. Both implement Display and std::error::Error, so they fit any standard error-handling pipeline.

Dependencies

The macros are part of the samp crate; no extra dependency is needed:

[dependencies]
samp = { package = "rust-samp", version = "3" }
log  = "0.4"      # for the log::{info, warn, error, debug, trace} macros

fern is only needed when using the Layer 3 DIY approach. The log crate is re-exported through samp::log to spare #[native] from leaking it into your Cargo.toml; depending on log explicitly is still recommended for clarity at the call sites.