172 lines
7.2 KiB
Text
172 lines
7.2 KiB
Text
|
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
|
|
||
|
|
// The timeline: what the app logged, the checks that failed, the events it
|
||
|
|
// emitted, and what the agent did, in one sequence stamped with frame
|
||
|
|
// numbers and source locations, so `crafter-debug log` shows cause next to
|
||
|
|
// effect:
|
||
|
|
//
|
||
|
|
// 1790 AGENT call Player_Fire {}
|
||
|
|
// 1838 WARN physics brace 17 strain 4.2 > limit Forts3D-Brace.cpp:212
|
||
|
|
//
|
||
|
|
// Debug::Log("net", "peer {} joined slot {}", peerId, slot); // also LogWarn, LogError
|
||
|
|
// Debug::Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 });
|
||
|
|
// if (!Debug::Check(mass > 0, "physics", "body {} has mass {}", id, mass)) return;
|
||
|
|
//
|
||
|
|
// The format string is checked at compile time, as std::format's is, and the
|
||
|
|
// caller's location is captured without a macro (macros don't cross
|
||
|
|
// `import`). Every function here is safe from any thread. The timeline
|
||
|
|
// keeps the last 8192 entries.
|
||
|
|
export module Crafter.Debug:Timeline;
|
||
|
|
import std;
|
||
|
|
import Crafter.Debug.Core;
|
||
|
|
import :Registry;
|
||
|
|
|
||
|
|
export namespace Crafter::Debug {
|
||
|
|
enum class EntryKind : std::uint8_t {
|
||
|
|
Log,
|
||
|
|
Warn,
|
||
|
|
Error,
|
||
|
|
Check,
|
||
|
|
Event,
|
||
|
|
Agent,
|
||
|
|
Pause,
|
||
|
|
Stdio,
|
||
|
|
};
|
||
|
|
|
||
|
|
// "log", "warn", "error", "check", "event", "agent", "pause", "stdio".
|
||
|
|
std::string_view EntryKindName(EntryKind kind);
|
||
|
|
|
||
|
|
struct TimelineEntry {
|
||
|
|
// 0 for a check failure that was counted but not stored (see Check).
|
||
|
|
std::uint64_t sequence = 0;
|
||
|
|
std::uint64_t frame = 0;
|
||
|
|
// Since the timeline started, on the steady clock.
|
||
|
|
double seconds = 0;
|
||
|
|
EntryKind kind = EntryKind::Log;
|
||
|
|
// The log category, or an event's name.
|
||
|
|
std::string category;
|
||
|
|
std::string message;
|
||
|
|
std::string file;
|
||
|
|
std::uint32_t line = 0;
|
||
|
|
// An event's payload.
|
||
|
|
Value payload;
|
||
|
|
// For a check: its site's failures so far, this one included.
|
||
|
|
std::uint64_t count = 1;
|
||
|
|
};
|
||
|
|
|
||
|
|
// A format string and where it was written, captured at the call site.
|
||
|
|
template<class... A>
|
||
|
|
struct LocFmt {
|
||
|
|
std::format_string<A...> format;
|
||
|
|
std::source_location location;
|
||
|
|
|
||
|
|
template<class S>
|
||
|
|
requires std::convertible_to<const S&, std::string_view>
|
||
|
|
consteval LocFmt(const S& text, std::source_location where = std::source_location::current()) : format(text), location(where) {}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export namespace Crafter::Debug::Detail {
|
||
|
|
// 0 for information (log, event, agent, pause, stdio), 1 for warn, 2 for
|
||
|
|
// error and check: what `log --level` compares against.
|
||
|
|
std::uint8_t Level(EntryKind kind);
|
||
|
|
|
||
|
|
struct TimelineQuery {
|
||
|
|
// Only entries after this sequence number: what a follower has seen.
|
||
|
|
std::uint64_t afterSequence = 0;
|
||
|
|
std::optional<std::uint64_t> sinceFrame;
|
||
|
|
// The newest this many of the matches; 0 for all of them.
|
||
|
|
std::size_t last = 0;
|
||
|
|
std::uint8_t minLevel = 0;
|
||
|
|
// A glob on the category; empty for any.
|
||
|
|
std::string category;
|
||
|
|
// A substring of the message or the category; empty for any.
|
||
|
|
std::string grep;
|
||
|
|
// Empty for any kind.
|
||
|
|
std::vector<EntryKind> kinds;
|
||
|
|
};
|
||
|
|
|
||
|
|
struct CheckSite {
|
||
|
|
std::string file;
|
||
|
|
std::uint32_t line = 0;
|
||
|
|
std::string category;
|
||
|
|
std::uint64_t failures = 0;
|
||
|
|
};
|
||
|
|
|
||
|
|
class Timeline {
|
||
|
|
public:
|
||
|
|
static Timeline& Instance();
|
||
|
|
|
||
|
|
// Stamps the entry with its sequence, frame and time, stores it and
|
||
|
|
// tells the listener. Returns its sequence number.
|
||
|
|
std::uint64_t Append(TimelineEntry entry);
|
||
|
|
// A failed Check. Counted per site; stored while the site has failed
|
||
|
|
// at most ten times and then at every power of two, so a check that
|
||
|
|
// fails every frame cannot push everything else out. The listener
|
||
|
|
// hears every failure, stored or not.
|
||
|
|
void RecordCheck(std::string_view category, std::string message, const std::source_location& location);
|
||
|
|
|
||
|
|
std::vector<TimelineEntry> Query(const TimelineQuery& query) const;
|
||
|
|
std::uint64_t LastSequence() const;
|
||
|
|
std::vector<CheckSite> CheckSites() const;
|
||
|
|
|
||
|
|
// Called with every stored entry and every check failure, on the
|
||
|
|
// thread that recorded it, after the timeline's lock is released:
|
||
|
|
// the session uses it to pause on a check and to wake followers.
|
||
|
|
void SetListener(std::function<void(const TimelineEntry&)> listener);
|
||
|
|
|
||
|
|
private:
|
||
|
|
mutable std::mutex mutex;
|
||
|
|
std::deque<TimelineEntry> entries;
|
||
|
|
std::uint64_t nextSequence = 1;
|
||
|
|
struct SiteCount {
|
||
|
|
std::string category;
|
||
|
|
std::uint64_t failures = 0;
|
||
|
|
};
|
||
|
|
std::map<std::pair<std::string, std::uint32_t>, SiteCount> checks;
|
||
|
|
std::shared_ptr<const std::function<void(const TimelineEntry&)>> listener;
|
||
|
|
std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
|
||
|
|
|
||
|
|
void Notify(const TimelineEntry& entry) const;
|
||
|
|
};
|
||
|
|
|
||
|
|
TimelineEntry MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload = {});
|
||
|
|
}
|
||
|
|
|
||
|
|
export namespace Crafter::Debug {
|
||
|
|
template<class... A>
|
||
|
|
void Log(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
||
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Log, category, std::format(format.format, std::forward<A>(args)...), format.location));
|
||
|
|
}
|
||
|
|
|
||
|
|
template<class... A>
|
||
|
|
void LogWarn(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
||
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Warn, category, std::format(format.format, std::forward<A>(args)...), format.location));
|
||
|
|
}
|
||
|
|
|
||
|
|
template<class... A>
|
||
|
|
void LogError(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
||
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Error, category, std::format(format.format, std::forward<A>(args)...), format.location));
|
||
|
|
}
|
||
|
|
|
||
|
|
// A soft assert: returns `ok`, and when it is false records the failure
|
||
|
|
// (the message is only formatted then, so a Check in hot code is cheap).
|
||
|
|
// An agent can make a failure pause the app (`pause --on-check`) or stop
|
||
|
|
// it in an attached debugger (`--trap`).
|
||
|
|
template<class... A>
|
||
|
|
bool Check(bool ok, std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
||
|
|
if (!ok) Detail::Timeline::Instance().RecordCheck(category, std::format(format.format, std::forward<A>(args)...), format.location);
|
||
|
|
return ok;
|
||
|
|
}
|
||
|
|
|
||
|
|
// A structured event, which `crafter-debug wait --event <name>` waits for.
|
||
|
|
template<class T>
|
||
|
|
void Emit(std::string_view event, const T& payload, std::source_location location = std::source_location::current()) {
|
||
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location, Debug::Encode(payload)));
|
||
|
|
}
|
||
|
|
|
||
|
|
inline void Emit(std::string_view event, std::source_location location = std::source_location::current()) {
|
||
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location));
|
||
|
|
}
|
||
|
|
}
|