diff --git a/.env.example b/.env.example index dd95d28..4e496fe 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/Cargo.lock b/Cargo.lock index d0a8a5c..579083b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -372,6 +372,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "clap" version = "4.6.1" @@ -1615,6 +1625,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2305,6 +2333,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -3072,6 +3106,7 @@ dependencies = [ "anyhow", "axum", "chrono", + "chrono-tz", "clap", "http-body-util", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index b922978..3e9ecf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/trapfalld/Cargo.toml b/crates/trapfalld/Cargo.toml index cd79e01..71ce63c 100644 --- a/crates/trapfalld/Cargo.toml +++ b/crates/trapfalld/Cargo.toml @@ -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 } diff --git a/crates/trapfalld/src/auth.rs b/crates/trapfalld/src/auth.rs index d2ce698..d9102e9 100644 --- a/crates/trapfalld/src/auth.rs +++ b/crates/trapfalld/src/auth.rs @@ -246,6 +246,7 @@ pub async fn require_auth(State(state): State, mut req: Request 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 { "" } @@ -83,6 +104,7 @@ impl Config { cors_origins: parse_cors_origins(), secure_cookie: parse_secure_cookie(), public_url: parse_public_url(), + timezone: parse_timezone(), } } @@ -144,6 +166,29 @@ fn parse_public_url() -> Option { .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::().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`, @@ -169,6 +214,7 @@ mod tests { cors_origins: vec![], secure_cookie: true, public_url: None, + timezone: "UTC".to_string(), } } diff --git a/crates/trapfalld/src/lib.rs b/crates/trapfalld/src/lib.rs index 851d059..9c03ea1 100644 --- a/crates/trapfalld/src/lib.rs +++ b/crates/trapfalld/src/lib.rs @@ -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; diff --git a/crates/trapfalld/src/log_time.rs b/crates/trapfalld/src/log_time.rs new file mode 100644 index 0000000..6839e58 --- /dev/null +++ b/crates/trapfalld/src/log_time.rs @@ -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::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::().is_ok()); + assert!("Not/A_Zone".parse::().is_err()); + assert!("UTC".parse::().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}"); + } +} diff --git a/crates/trapfalld/src/main.rs b/crates/trapfalld/src/main.rs index 46633ff..ac49134 100644 --- a/crates/trapfalld/src/main.rs +++ b/crates/trapfalld/src/main.rs @@ -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()), ) @@ -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() { "" } else { "" }, config.dsn_host().unwrap_or_else(|| "".into()), + config.timezone, ); // Channel: ingest → digest diff --git a/crates/trapfalld/src/openapi.yaml b/crates/trapfalld/src/openapi.yaml index 2329f32..ae7ed97 100644 --- a/crates/trapfalld/src/openapi.yaml +++ b/crates/trapfalld/src/openapi.yaml @@ -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: diff --git a/crates/trapfalld/src/server.rs b/crates/trapfalld/src/server.rs index f47806f..ca80c9f 100644 --- a/crates/trapfalld/src/server.rs +++ b/crates/trapfalld/src/server.rs @@ -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 @@ -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) -> Json { + Json(PublicConfig { timezone: state.config.timezone.to_string() }) +} + async fn list_projects(State(state): State) -> Result>, StatusCode> { let store = state.store.clone(); let projects = store.list_projects().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/trapfalld/tests/integration.rs b/crates/trapfalld/tests/integration.rs index 5f52141..9c06e5c 100644 --- a/crates/trapfalld/tests/integration.rs +++ b/crates/trapfalld/tests/integration.rs @@ -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, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 46a3912..851e561 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c75d6ba..e9262eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: > diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 32f89db..80f51a2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -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 @@ -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: diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ef32499..d635e6a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -28,6 +28,11 @@ export interface SetupStatus { needs_setup: boolean; } +export interface PublicConfig { + /** IANA timezone name, e.g. "Asia/Jakarta" or "UTC". Display only. */ + timezone: string; +} + export interface SetupResponse { user: UserInfo; project_slug: string; @@ -314,6 +319,10 @@ class ApiClient { async listEnvironments(projectSlug: string): Promise { return this.get(`/projects/${projectSlug}/environments`); } + + async getPublicConfig(): Promise { + return this.get('/config'); + } } diff --git a/web/src/lib/timezone.svelte.ts b/web/src/lib/timezone.svelte.ts new file mode 100644 index 0000000..1f96c82 --- /dev/null +++ b/web/src/lib/timezone.svelte.ts @@ -0,0 +1,56 @@ +/** + * Display-timezone store. + * + * The TrapFall server persists all timestamps in UTC (immutable invariant). + * This store holds the configured **display** timezone fetched from + * `/api/0/config`. The pure formatting logic lives in `timezone.ts` so it can + * be used in non-component code and tests without pulling in runes. + * + * Defaults to the browser's local timezone until the server config loads, so + * absolute timestamps are never wrong by more than a brief initial fetch. + */ +import { api } from './api'; +import { formatInTimezone, setTimezoneAccessor } from './timezone'; + +let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'); + +// Wire the live timezone into the plain-TS accessor so utils/tests can read +// it without importing this runes module. +setTimezoneAccessor(() => timezone); + +let loaded = false; +let inflight: Promise | null = null; + +/** + * Load the server's display timezone once per session. Safe to call + * repeatedly; concurrent callers share a single in-flight request rather + * than firing duplicate fetches. + */ +export async function loadTimezone(): Promise { + if (loaded) return; + if (!inflight) { + inflight = (async () => { + try { + const cfg = await api.getPublicConfig(); + if (cfg.timezone) timezone = cfg.timezone; + loaded = true; + } catch { + // Keep the browser-default fallback. Not marked loaded so a later + // call can retry (e.g. transient network failure during boot). + } finally { + inflight = null; + } + })(); + } + return inflight; +} + +/** Current display timezone (IANA name). */ +export function getTimezone(): string { + return timezone; +} + +/** Format a UTC ISO timestamp in the configured display timezone. */ +export function formatInTz(iso: string): string { + return formatInTimezone(iso, timezone); +} diff --git a/web/src/lib/timezone.test.ts b/web/src/lib/timezone.test.ts new file mode 100644 index 0000000..b57e371 --- /dev/null +++ b/web/src/lib/timezone.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { formatInTimezone } from './timezone'; + +describe('formatInTimezone', () => { + const iso = '2026-07-01T03:39:29Z'; + + it('renders in the given IANA timezone', () => { + const jakarta = formatInTimezone(iso, 'Asia/Jakarta'); + const utc = formatInTimezone(iso, 'UTC'); + // Jakarta (UTC+7) and UTC (UTC+0) must differ for a 03:xxZ timestamp — + // proving the timezone actually affects the output. + expect(jakarta).not.toEqual(utc); + expect(jakarta).toContain('10'); // 03 + 7h = 10 + }); + + it('renders in UTC', () => { + const out = formatInTimezone(iso, 'UTC'); + expect(out).toContain('39'); // minutes are timezone-stable + expect(out).toMatch(/3|03/); // hour 3 (locale-dependent padding) + }); + + it('returns empty string for empty input', () => { + expect(formatInTimezone('', 'UTC')).toBe(''); + }); + + it('falls back gracefully for an invalid timezone', () => { + // Should not throw; falls back to browser locale formatting. + const out = formatInTimezone(iso, 'Not/A_Real_Zone'); + expect(out.length).toBeGreaterThan(0); + }); + + it('returns the raw input for an unparseable timestamp', () => { + expect(formatInTimezone('not-a-date', 'UTC')).toBe('not-a-date'); + }); +}); diff --git a/web/src/lib/timezone.ts b/web/src/lib/timezone.ts new file mode 100644 index 0000000..c7d78c0 --- /dev/null +++ b/web/src/lib/timezone.ts @@ -0,0 +1,46 @@ +/** + * Timezone formatting logic — pure functions, no Svelte runes. + * + * Kept separate from the reactive store so it stays importable in plain + * test files and non-component modules. The TrapFall server persists all + * timestamps in UTC (immutable invariant); these helpers convert UTC ISO + * strings into the configured display timezone. + */ + +/** + * Format a UTC ISO timestamp in the given IANA timezone. + * Falls back to browser locale formatting on an invalid zone or input. + */ +export function formatInTimezone(iso: string, timezone: string): string { + if (!iso) return ''; + const date = new Date(iso); + // Guard against non-date input (e.g. malformed payloads) so callers never + // see "Invalid Date" — return the raw string instead. + if (Number.isNaN(date.getTime())) return iso; + try { + return new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + dateStyle: 'short', + timeStyle: 'medium' + }).format(date); + } catch { + return date.toLocaleString(); + } +} + +/** + * Accessor injected by the reactive store so non-rune modules (e.g. utils.ts, + * tests) can read the active display timezone without importing the runes + * module. Defaults to the browser zone until the store wires it up. + */ +let tzAccessor: () => string = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + +/** Internal — called once by the store to supply the live timezone. */ +export function setTimezoneAccessor(fn: () => string): void { + tzAccessor = fn; +} + +/** The currently active display timezone (IANA name). */ +export function activeTimezone(): string { + return tzAccessor(); +} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index a81f0b0..6aee1fa 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,6 +1,7 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import type { Snippet } from 'svelte'; +import { formatInTimezone, activeTimezone } from './timezone'; // ── Shadcn UI helpers ───────────────────────────────────────────────── @@ -82,7 +83,9 @@ export function statusTextClass(status: string): string { */ export function formatTime(iso: string): string { if (!iso) return ''; - return new Date(iso).toLocaleString(); + // Use the active display timezone from the store (server-configured), + // falling back to the browser zone if the store hasn't loaded yet. + return formatInTimezone(iso, activeTimezone()); } /** diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 065a8c0..ab0dcc9 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import '../app.css'; import { ModeWatcher } from 'mode-watcher'; import { getAuthStore } from '$lib/stores/auth.svelte'; + import { loadTimezone } from '$lib/timezone.svelte'; import { onMount } from 'svelte'; let { children }: { children: import('svelte').Snippet } = $props(); @@ -10,6 +11,7 @@ onMount(() => { auth.init(); + loadTimezone(); });