Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ TRAPFALL_LISTEN=0.0.0.0:9090
# Log level: trace, debug, info, warn, error (default: info)
RUST_LOG=trapfall=info

# ── Display Timezone ──────────────────────────────────────────────────
# IANA timezone for DISPLAY ONLY (log timestamps + dashboard). All stored
# timestamps remain UTC RFC3339; this never affects data.
# Examples: Asia/Jakarta, America/New_York, Europe/London
# Default: UTC
TRAPFALL_TIMEZONE=Asia/Jakarta

# ── Database ───────────────────────────────────────────────────────────
# Database URL. Supports sqlite: and postgres:// schemes.
# Bare paths default to sqlite: (e.g. /data/trapfall.db → sqlite:/data/trapfall.db)
Expand Down
35 changes: 35 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ clap = { version = "4", features = ["derive", "env"] }

# Time
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"

# UUID
uuid = { version = "1", features = ["v4", "serde"] }
Expand Down
1 change: 1 addition & 0 deletions crates/trapfalld/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ serde_json = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
tracing = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
tower-http = { workspace = true }
anyhow = { workspace = true }
tracing-subscriber = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/trapfalld/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ pub async fn require_auth(State(state): State<AppState>, mut req: Request<axum::
|| path.ends_with("/auth/logout")
|| path == "/health"
|| path == "/metrics"
|| path == "/api/0/config"
|| path.contains("/envelope/");
if is_public {
return next.run(req).await;
Expand Down
46 changes: 46 additions & 0 deletions crates/trapfalld/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ pub struct Config {
/// Set to `false`/`0` for local HTTP development.
#[serde(default = "default_secure_cookie")]
pub secure_cookie: bool,
/// Display timezone (`TRAPFALL_TIMEZONE`, default `UTC`).
///
/// IANA timezone name (e.g. `Asia/Jakarta`, `America/New_York`). Used
/// **for display only** — log timestamps and the `/api/0/config` payload
/// consumed by the dashboard. All persisted timestamps remain UTC; this
/// never affects storage. Invalid values fall back to `UTC` with a warn.
#[serde(default = "default_timezone")]
pub timezone: String,
/// Public base URL of the TrapFall instance
/// (`TRAPFALL_PUBLIC_URL` / legacy `TRAPFALL_DSN_HOST`).
///
Expand All @@ -44,7 +52,20 @@ fn default_secure_cookie() -> bool {
true
}

/// Default display timezone: UTC.
fn default_timezone() -> String {
"UTC".to_string()
}

impl Config {
/// Parsed IANA timezone for display (UTC on parse failure).
///
/// All persisted timestamps stay UTC RFC3339; this is used only by log
/// formatting and the public config endpoint.
pub fn tz(&self) -> chrono_tz::Tz {
self.timezone.parse().unwrap_or(chrono_tz::UTC)
}

/// Returns "Secure" if `secure_cookie` is true, empty string otherwise.
pub fn cookie_secure_flag(&self) -> &'static str {
if self.secure_cookie { "Secure" } else { "" }
Expand Down Expand Up @@ -83,6 +104,7 @@ impl Config {
cors_origins: parse_cors_origins(),
secure_cookie: parse_secure_cookie(),
public_url: parse_public_url(),
timezone: parse_timezone(),
}
}

Expand Down Expand Up @@ -144,6 +166,29 @@ fn parse_public_url() -> Option<String> {
.map(|s| s.trim().to_string())
}

/// Parse `TRAPFALL_TIMEZONE` as an IANA timezone name (e.g. `Asia/Jakarta`).
/// Invalid/unset values fall back to `UTC`. Invalid values emit a warning.
pub fn parse_timezone() -> String {
match std::env::var("TRAPFALL_TIMEZONE") {
Ok(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "UTC".to_string();
}
if trimmed.parse::<chrono_tz::Tz>().is_ok() {
trimmed.to_string()
} else {
tracing::warn!(
timezone = %trimmed,
"Invalid TRAPFALL_TIMEZONE — falling back to UTC. Use an IANA name like 'Asia/Jakarta'."
);
"UTC".to_string()
}
}
Err(_) => "UTC".to_string(),
}
}

/// Normalize a user-provided public-URL value into a bare `host[:port]`.
///
/// Accepts all of: `https://trapfall.example.com`,
Expand All @@ -169,6 +214,7 @@ mod tests {
cors_origins: vec![],
secure_cookie: true,
public_url: None,
timezone: "UTC".to_string(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/trapfalld/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod attachment_storage;
pub mod auth;
pub mod config;
pub mod digest;
pub mod log_time;
pub mod metrics;
pub mod migrate;
pub mod rate_limit;
Expand Down
56 changes: 56 additions & 0 deletions crates/trapfalld/src/log_time.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! Log timestamp formatting in the configured display timezone.
//!
//! Storage timestamps always remain UTC RFC3339 (see `Config::timezone`).
//! This module only customizes the timestamps that `tracing` emits to stdout
//! so operators reading `docker logs` see wall-clock local time.

use crate::config::parse_timezone;
use chrono::{DateTime, Utc};
use tracing_subscriber::fmt::time::FormatTime;

/// `tracing` timer that renders timestamps in the configured IANA timezone.
///
/// Falls back to UTC if the timezone cannot be determined at init time.
pub struct LocalTzTimer(chrono_tz::Tz);

impl LocalTzTimer {
/// Build from the configured timezone. Parses `TRAPFALL_TIMEZONE` (or
/// defaults to UTC) — used early in startup before `Config` is fully built.
pub fn from_env() -> Self {
let name = parse_timezone();
Self(name.parse().unwrap_or(chrono_tz::UTC))
}
}

impl FormatTime for LocalTzTimer {
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
let now: DateTime<Utc> = Utc::now();
let local = now.with_timezone(&self.0);
// Compact, sortable: 2026-07-01 10:39:29.163 +07:00
w.write_str(&local.format("%Y-%m-%d %H:%M:%S%.3f %z").to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_iana_zone_validates() {
// Direct unit test of the parser's parse step (env mutation is unsafe
// in edition 2024 and racy). UTC on empty/invalid, IANA name passed
// through on valid input.
assert!("Asia/Jakarta".parse::<chrono_tz::Tz>().is_ok());
assert!("Not/A_Zone".parse::<chrono_tz::Tz>().is_err());
assert!("UTC".parse::<chrono_tz::Tz>().is_ok());
}
#[test]
fn format_time_emits_offset() {
let timer = LocalTzTimer(chrono_tz::Asia::Jakarta);
let mut buf = String::new();
let mut writer = tracing_subscriber::fmt::format::Writer::new(&mut buf);
timer.format_time(&mut writer).unwrap();
// Jakarta is UTC+7
assert!(buf.contains("+0700"), "expected +0700 offset in: {buf}");
}
}
6 changes: 4 additions & 2 deletions crates/trapfalld/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ enum DbCommands {
async fn main() -> Result<()> {
let cli = Cli::parse();

// Init tracing
// Init tracing — timestamps in the configured display timezone (UTC by default).
tracing_subscriber::fmt()
.with_timer(trapfalld::log_time::LocalTzTimer::from_env())
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| cli.log_level.clone().into()),
)
Expand Down Expand Up @@ -191,12 +192,13 @@ async fn run_server(store: Store, listen: String, db_url: String) -> Result<()>

let config = Config::from_env(&db_url, &listen);
info!(
"Config: db={}, listen={}, secure_cookie={}, cors_origins={}, public_url={}",
"Config: db={}, listen={}, secure_cookie={}, cors_origins={}, public_url={}, timezone={}",
config.db_path.display(),
config.listen_addr,
config.secure_cookie,
if config.cors_origins.is_empty() { "<all>" } else { "<restricted>" },
config.dsn_host().unwrap_or_else(|| "<unset, will use Host header>".into()),
config.timezone,
);

// Channel: ingest → digest
Expand Down
22 changes: 22 additions & 0 deletions crates/trapfalld/src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ paths:
"200":
description: Prometheus formatted metrics

/api/0/config:
get:
tags: [System]
summary: Public runtime config (display only)
description: >
Unauthenticated runtime config consumed by the dashboard. Returns the
configured display timezone (IANA name). No secrets. All persisted
timestamps remain UTC; this value only affects display formatting.
operationId: getPublicConfig
responses:
"200":
description: Public runtime configuration
content:
application/json:
schema:
type: object
properties:
timezone:
type: string
example: Asia/Jakarta
description: IANA timezone name used for display (UTC default)

# ── Setup ────────────────────────────────────────────────────────

/api/0/setup:
Expand Down
13 changes: 13 additions & 0 deletions crates/trapfalld/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/metrics", get(crate::metrics::metrics))
.route("/api/0/config", get(get_public_config))
// Public ingest API (DSN key auth)
.route("/api/{project_id}/envelope/", post(ingest_envelope))
// Auth + dashboard routes
Expand Down Expand Up @@ -180,6 +181,18 @@ async fn health() -> &'static str {
"ok"
}

/// Public, unauthenticated runtime config for the dashboard (display only —
/// never secrets). The SPA needs the configured timezone to render absolute
/// timestamps correctly; persisting UTC stays untouched.
#[derive(serde::Serialize)]
struct PublicConfig {
timezone: String,
}

async fn get_public_config(State(state): State<AppState>) -> Json<PublicConfig> {
Json(PublicConfig { timezone: state.config.timezone.to_string() })
}

async fn list_projects(State(state): State<AppState>) -> Result<Json<Vec<trapfall_proto::Project>>, StatusCode> {
let store = state.store.clone();
let projects = store.list_projects().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Expand Down
1 change: 1 addition & 0 deletions crates/trapfalld/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ fn make_state(store: Store, rate_limiter: RateLimiter) -> AppState {
cors_origins: Vec::new(),
secure_cookie: false,
public_url: None,
timezone: "UTC".to_string(),
};
AppState {
store,
Expand Down
1 change: 1 addition & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ services:
- TRAPFALL_DATABASE_URL=${TRAPFALL_DATABASE_URL:-sqlite:/data/trapfall.db}
- TRAPFALL_SECURE_COOKIE=${TRAPFALL_SECURE_COOKIE:-true}
- TRAPFALL_CORS_ORIGINS=${TRAPFALL_CORS_ORIGINS:-}
- TRAPFALL_TIMEZONE=${TRAPFALL_TIMEZONE:-UTC}
command: >
serve
--listen 0.0.0.0:9090
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
environment:
- RUST_LOG=${RUST_LOG:-trapfall=info}
- TRAPFALL_SECURE_COOKIE=${TRAPFALL_SECURE_COOKIE:-false}
- TRAPFALL_TIMEZONE=${TRAPFALL_TIMEZONE:-UTC}
# Database URL: sqlite path (default) or postgres://... (requires postgres feature)
- TRAPFALL_DATABASE_URL=${TRAPFALL_DATABASE_URL:-sqlite:/data/trapfall.db}
command: >
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` an
| `TRAPFALL_LISTEN` | `0.0.0.0:9090` | HTTP listen address |
| `TRAPFALL_SECURE_COOKIE` | `true` | Set `false` for HTTP local dev |
| `TRAPFALL_CORS_ORIGINS` | *(empty = allow all)* | Comma-separated origins |
| `TRAPFALL_TIMEZONE` | `UTC` | IANA timezone for display only (logs + dashboard); storage stays UTC |
| `RUST_LOG` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` |

## Database URL
Expand Down Expand Up @@ -52,6 +53,22 @@ TRAPFALL_CORS_ORIGINS=https://trapfall.yourcompany.com
RUST_LOG=trapfall=info
```

## Display Timezone

`TRAPFALL_TIMEZONE` sets the timezone for **display only** — log timestamps
and the dashboard. **All stored timestamps remain UTC** (the immutable
invariant used by Sentry SDKs, DSNs, and retention); this setting never
affects stored data, only how times are rendered.

```bash
# Jakarta time (WIB, UTC+7) for logs + dashboard
TRAPFALL_TIMEZONE=Asia/Jakarta
```

Use any [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
(e.g. `America/New_York`, `Europe/London`, `Asia/Singapore`). Invalid values
fall back to `UTC` with a warning at startup.

## Docker Compose

The included `docker-compose.yml` is pre-configured:
Expand Down
Loading
Loading