chobit/shared/godot/autoloads/flight_recorder.gd
2026-03-28 15:06:23 -07:00

118 lines
3.5 KiB
GDScript

extends Node
## Structured event recorder matching @lilith/flight-recorder schema.
## Reads DEBUG flag from ../.env (relative to res://).
## When DEBUG=TRUE: streams colored human-readable lines to stdout
## and appends JSON entries to ../logs/flight_recorder.jsonl.
##
## Schema: {"date","time","source","type","content","metadata"}
## Usage: FlightRecorder.record("system.event", "Human description", {"key": value})
const RESET := "\u001b[0m"
const DIM := "\u001b[90m"
const RED := "\u001b[31m"
const GREEN := "\u001b[32m"
const YELLOW := "\u001b[33m"
const BLUE := "\u001b[34m"
const MAGENTA := "\u001b[35m"
const CYAN := "\u001b[36m"
const WHITE := "\u001b[37m"
const TYPE_PAD := 24
const PREFIX_COLORS := {
"llm": CYAN,
"stt": GREEN,
"tts": MAGENTA,
"companion": YELLOW,
"config": BLUE,
"app_state": BLUE,
"flight_recorder": DIM,
}
var _debug: bool = false
var _record_file: FileAccess
var _log_path: String
func _ready() -> void:
_debug = _read_debug_flag()
if not _debug:
return
_log_path = _project_root().path_join("logs/flight_recorder.jsonl")
DirAccess.make_dir_recursive_absolute(_project_root().path_join("logs"))
_record_file = FileAccess.open(_log_path, FileAccess.READ_WRITE)
if _record_file != null:
_record_file.seek_end(0)
else:
_record_file = FileAccess.open(_log_path, FileAccess.WRITE)
record("flight_recorder.init", "Flight recorder started", {"path": _log_path})
func record(type: String, content: String, metadata: Dictionary = {}) -> void:
if not _debug:
return
var dt := Time.get_datetime_dict_from_system()
var ms := Time.get_ticks_msec() % 1000
var time_str := "%02d:%02d:%02d.%03d" % [dt.hour, dt.minute, dt.second, ms]
var entry := {
"date": "%04d-%02d-%02d" % [dt.year, dt.month, dt.day],
"time": time_str,
"source": "godot",
"type": type,
"content": content,
"metadata": metadata if not metadata.is_empty() else null,
}
var json_line := JSON.stringify(entry)
print(_format_colored(time_str, type, content, entry.metadata))
if _record_file != null:
_record_file.store_line(json_line)
_record_file.flush()
func _format_colored(time_str: String, type: String, content: String, metadata: Variant) -> String:
var color := _color_for_type(type)
var padded_type := type
if padded_type.length() < TYPE_PAD:
padded_type += " ".repeat(TYPE_PAD - padded_type.length())
var meta_str := _format_metadata(metadata)
return DIM + time_str + RESET + " " + color + padded_type + RESET + " " + content + meta_str
func _color_for_type(type: String) -> String:
if type.ends_with(".error"):
return RED
var prefix := type.split(".")[0]
return PREFIX_COLORS.get(prefix, WHITE)
func _format_metadata(metadata: Variant) -> String:
if metadata == null:
return ""
if metadata is Dictionary and metadata.is_empty():
return ""
var parts: PackedStringArray = []
for key: String in metadata:
parts.append("%s=%s" % [key, str(metadata[key])])
return " " + DIM + "(" + ", ".join(parts) + ")" + RESET
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _record_file != null:
_record_file.close()
func _project_root() -> String:
return ProjectSettings.globalize_path("res://").path_join("..")
func _read_debug_flag() -> bool:
var env_path := _project_root().path_join(".env")
var file := FileAccess.open(env_path, FileAccess.READ)
if file == null:
return false
while not file.eof_reached():
var line := file.get_line().strip_edges()
if line.begins_with("DEBUG="):
var value := line.substr(6).strip_edges().to_upper()
return value == "TRUE" or value == "1"
return false