跳转至

运行时会话轨迹

GFSessionTraceUtility 用于记录一段有界、结构化的运行时事件轨迹。它适合回答“玩家在报错前打开了哪些页面、触发了什么输入、存档流程走到了哪一步”,但不会扫描场景树、自动读取节点属性,也不会替项目上传玩家数据。

与逐帧录像或完整状态转储相比,会话轨迹只记录项目显式声明的语义事件。例如,一次 UI 故障可以只保留最近的 route.openedinput.confirmedsave.completed,而不是保存每一帧的全部节点状态。这样更容易控制数据体量,也更容易向玩家解释实际采集了什么。

建立最小轨迹

工具不会自动注册通道。项目应先声明允许记录的通道,再开始会话;未知通道会 fail closed:

var trace: GFSessionTraceUtility = GFSessionTraceUtility.new()
trace.max_events = 256
trace.max_event_buffer_bytes = 512 * 1024
trace.max_event_bytes = 8 * 1024

trace.register_channel(&"input", {
    "max_events": 80,
})
trace.register_channel(&"route", {
    "max_events": 120,
})
trace.register_channel(&"save", {
    "max_events": 24,
})

trace.start_session(&"play_session", {
    "build_channel": "public",
    "game_mode": &"campaign",
})

如果多个系统都需要查询同一条轨迹,可以在项目 Installer 中注册这个实例;若只有一个局部调试流程使用,也可以由该流程直接持有。GF 不会因类存在而自动开始采集。

显式 session_id 只应使用非敏感、本地、低基数的标识,例如 play_session 或自动生成值。它用于事件归组,不会自动识别账号 ID、网络令牌等业务秘密;超长通道、事件和 provider ID 会 fail closed,而不是截断后与既有白名单发生别名。

事件 ID 和载荷 schema 由项目定义。建议记录稳定、低基数的操作结果,不要直接记录玩家输入的自由文本、账号 token 或完整存档:

var result: Dictionary = trace.record_event(
    &"route",
    &"opened",
    {
        "route_id": &"settings",
        "entry": &"pause_menu",
    },
    {
        "simulation_tick": simulation_tick,
    }
)

if not GFVariantData.get_option_bool(result, "ok"):
    var reason: StringName = GFVariantData.get_option_string_name(result, "reason")
    push_warning("Session trace rejected event: %s" % reason)

每条成功事件包含会话 ID、单调序号、相对会话时间、可选模拟 tick、通道、事件 ID、载荷和 metadata。载荷和单次记录提供的 metadata 在进入内存轨迹前会经过 GFReportValueCodec 编码和当前字节预算约束;默认使用 privacy profile。

会话 context、通道 metadata 和 provider metadata 会被工具长期保存,并可能在 profile 改变后重复用于快照或事件。它们因此始终按 privacy 安全下限编码,不会因为注册时临时使用 debugsupport 而保留对象实例 ID、节点名称或原始路径。确实需要本地调试细节时,应把经过项目字段白名单筛选的数据放进单次事件,而不是依赖长期目录 metadata。

在关键点采集状态

有些问题需要同时知道“发生了什么”和“当时的少量状态”。项目可以注册同步 snapshot provider,并只在报错、检查点或用户主动生成支持报告时调用它:

trace.register_snapshot_provider(
    &"player_summary",
    &"save",
    func() -> Dictionary:
        return {
            "checkpoint_id": current_checkpoint_id,
            "health_bucket": current_health_bucket,
            "inventory_count": inventory_count,
        }
)

var capture: Dictionary = trace.capture_snapshot_provider(
    &"player_summary",
    { "simulation_tick": simulation_tick }
)

Provider 必须快速、同步、无参数且没有副作用。读取普通轨迹或构建报告不会自动执行 provider;只有显式调用 capture_snapshot_provider() 且当前会话与目标通道允许记录时才会采集。这一边界避免被拒绝的诊断请求仍执行项目回调,也避免任意节点被隐式反射。

接入支持报告

build_snapshot() 返回普通、JSON-safe 的有界字典,可以作为 GFSupportReportUtility 的项目分区。注册分区仍不代表自动进入报告;构建报告时还要显式启用 include_sections

var reports: GFSupportReportUtility = (
    Gf.get_utility(GFSupportReportUtility, true) as GFSupportReportUtility
)
reports.register_section(
    &"session_trace",
    func(_options: Dictionary) -> Dictionary:
        return trace.build_snapshot({
            "limit": 120,
            "include_channel_catalog": false,
            "include_provider_catalog": false,
        })
)

var report: Dictionary = reports.build_report(
    "设置页面无法返回",
    { "include_sections": true }
)

面向玩家的报告入口应先展示采集预览并取得适当许可。默认脱敏会处理路径、对象和非 JSON 原生类型,但不会理解项目自由文本里的业务秘密;字段白名单、账号数据分类和用户同意仍由项目负责。

可选的跨重启本地 journal

内存轨迹会随进程结束消失。需要在崩溃或强退后检查最近事件时,可以显式配置 GFLogSink;本地 JSONL 是一种可选实现:

var journal: GFJsonLineLogSink = GFJsonLineLogSink.new()
journal.file_path = "user://diagnostics/session_trace.jsonl"
journal.file_open_mode = GFJsonLineLogSink.FileOpenMode.APPEND
journal.flush_interval_msec = 250

trace.max_journal_events = 1000
if not trace.configure_journal_sink(journal, {
    "initialize": true,
    "shutdown_on_dispose": true,
    "flush_after_write": false,
}):
    push_error("Session Trace journal 配置失败;检查 sink 与脱敏 profile。")

max_journal_events 限制单次会话写入数量;stop_session()dispose() 会刷新 journal。需要更强的每事件落盘保证时可启用 flush_after_write,但这会增加磁盘同步成本。文件轮换、保留期、加密、上传和玩家删除入口仍是项目策略,不由 Session Trace 自动决定。

Journal 会先按 Session Trace 当前 redaction_profile 编码事件,再交给 sink。为了避免已经放宽的数据跨过更严格的输出边界,轨迹 profile 必须至少与 sink 的 get_report_redaction_profile() 声明一样严格:基础自定义 GFLogSink 默认声明 publicGFJsonLineLogSink 声明 debugGFBatchedLogSink 声明 privacy。不安全或未知的组合会让 configure_journal_sink() 返回 false,且不会部分替换既有配置;每次写入还会重新读取声明,因此配置后无论项目把轨迹 profile 改弱,还是自定义 sink 动态改变输出 profile,后续不安全写入都会 fail closed,并计入 journal_dropped_event_count

Sink 的 get_report_redaction_profile()init()write()flush()shutdown() 都位于重入保护边界内。回调中触发新的轨迹写入不会递归进入同一 sink;configure_journal_sink(null)、清除或释放会先原子断开引用,再完成延迟清理。替换 sink 时,如果旧 sink 的清理回调主动置空配置,置空请求优先且外层替换返回 false,调用方可在回调结束后显式重试。同一个 sink 实例再次配置时只原位更新所有权和刷新选项,不会把仍要继续使用的实例提前 flush()shutdown();只有新配置再次显式传入 initialize = true 时才会调用 init()。自定义 sink 仍应保持回调快速、同步,并避免执行项目业务副作用。

容量与读取

  • max_events:内存最多保留的事件数,超限时淘汰最旧事件。
  • max_event_buffer_bytes:内存事件缓冲的总字节上限;上下文与通道/provider 目录另受各自数量和单份 metadata 预算约束。
  • max_event_bytes:单事件上限;过大的事件会以稳定原因拒绝,而不是无界写入。
  • 通道 max_events / max_event_bytes:为高频通道设置更严格的局部预算。
  • get_events(limit, filters):按通道、事件 ID 或序号范围读取最近事件。
  • get_debug_snapshot():只返回计数、容量、拒绝原因和 journal 状态,不包含完整载荷。
  • build_snapshot(options):生成可交给支持报告、调试面板或离线分析器的结构化快照。

dropped_event_count 表示容量淘汰,rejected_event_count 表示事件未进入轨迹。两者应该分别观察:前者通常意味着需要调整采样或预算,后者通常意味着通道未注册、已禁用、会话未开始或单事件过大。

使用边界

Session Trace 不是输入录像、确定性重放、崩溃捕获器、性能 profiler 或远程监控 SDK。它只提供通用的事件包络、显式 provider、容量治理、脱敏边界和可选 sink。项目仍负责:

  • 决定哪些业务事件值得记录,以及每个事件的最小字段 schema;
  • 在主线程或自己的同步点记录事件,并维护模拟 tick 的含义;
  • 处理玩家许可、访问控制、保留期、加密和远端传输;
  • 把轨迹与构建版本、存档版本、网络会话或崩溃报告关联;
  • 若需要真正重放,另行保存确定性输入、随机种子和权威状态。