Defold 分析与崩溃上报

系统讲解 Defold 项目的埋点与线上监控方案:事件模型与命名规范、客户端事件队列与本地缓冲、批量上报与 http.request 实践、sys.set_error_handler 崩溃捕获、错误堆栈与上下文采集、会话与用户标识、隐私合规脱敏,以及线上告警策略。

引言

游戏上线才是问题的开始:玩家在哪一关流失、哪个机型闪退最多、新手引导第几步被卡住——没有数据就只能靠猜。Defold 本身不内置分析 SDK,所有埋点、缓冲、上报、崩溃捕获都要自己在 Lua 层搭起来。本文从事件模型设计讲起,覆盖客户端队列与本地缓冲、批量上报与网络请求、崩溃捕获与堆栈采集、会话与用户标识、隐私合规脱敏,最后给出线上监控与告警的落地方式。

前置阅读:HTTP 请求与异步回调、调试与性能分析工具。


目录


1. 埋点模型与事件设计

1. 事件的四要素

一个可用的事件模型至少要有四个字段:

字段说明示例
name事件名,全局唯一level_complete
ts时间戳(毫秒)1727769600000
session会话 IDs_8f3a2c
props自定义属性表{ level = 3, time = 42.5 }

2. 事件命名规范

对象_动作          推荐
level_start
level_complete
level_fail
item_purchase
shop_open
tutorial_step

不要用:StartLevel、start-level、开始关卡(中文名不利于聚合)

规则:全部小写、下划线分隔;名词在前动词在后,便于按前缀聚合;属性用 snake_case,嵌套不超过两层。

3. 埋点分级

级别用途上报时机
关键事件付费、关卡完成立即上报
一般事件界面打开、按钮点击批量上报
调试事件内部测试用仅开发包上报
-- analytics.lua 对外接口
local M = {}

function M.track(name, props)
    local event = {
        name    = name,
        ts      = os.time() * 1000,
        session = M.session_id,
        props   = props or {},
    }
    M.enqueue(event)
end

return M

踩坑:不要在埋点属性里塞大对象(比如整个背包表)。事件体积会迅速膨胀,而且序列化开销会拖慢帧率。属性只放标量和短字符串。


2. 客户端事件队列与缓冲

1. 为什么需要队列

网络请求是异步且可能失败的。如果每次埋点都直接发请求,弱网下会堆积大量失败请求,还会触发平台的请求频率限制。

埋点 → 内存队列 → 达到阈值或定时 → 批量上报 → 成功则清空
                                          → 失败则回退到本地存储

2. 内存队列实现

-- analytics/queue.lua
local M = {
    items    = {},
    max_size = 100,
}

function M.push(event)
    table.insert(M.items, event)
    if #M.items > M.max_size then
        table.remove(M.items, 1)   -- 丢弃最旧事件,保护内存
    end
end

function M.drain(n)
    local batch = {}
    for i = 1, math.min(n, #M.items) do
        table.insert(batch, M.items[i])
    end
    for _ = 1, #batch do
        table.remove(M.items, 1)
    end
    return batch
end

return M

3. 本地持久化缓冲

应用被杀进程时内存队列会丢失,用 sys.save 把队列落盘:

local FILE = "analytics_pending"

function M.persist()
    local ok, err = pcall(sys.save, FILE, M.items)
    if not ok then
        print("[analytics] 落盘失败:", tostring(err))
    end
end

function M.restore()
    local data = sys.load(FILE)
    if data and #data > 0 then
        M.items = data
        print("[analytics] 恢复待上报事件:", #data)
    end
end

注意:sys.save 使用 sys.get_save_file(application_id, filename) 的路径规则。写入前要确认应用标识已配置,否则会写到临时目录,重装即丢。


3. 批量上报与网络请求

1. http.request 基础

local function post_batch(batch, on_done)
    local body = json.encode({ events = batch })
    local headers = {
        ["Content-Type"] = "application/json",
        ["X-Api-Key"]    = ANALYTICS_KEY,
    }
    http.request("https://api.example.com/v1/events", "POST", function(self, id, response)
        on_done(response.status == 200)
    end, headers, body)
end

参数说明:

参数类型说明
urlstring上报端点,必须 https
methodstringGET 或 POST
callbackfunction回调签名 (self, id, response)
headerstable请求头
post_datastring请求体,GET 时为 nil
optionstable超时、重试等,可选

2. 定时批量上报

local FLUSH_INTERVAL = 10.0     -- 每 10 秒尝试上报
local BATCH_SIZE     = 20

function update(self, dt)
    self.timer = (self.timer or 0) + dt
    if self.timer >= FLUSH_INTERVAL and not self.sending then
        self.timer = 0
        flush(self)
    end
end

function flush(self)
    if queue.size() == 0 then return end
    self.sending = true
    local batch = queue.drain(BATCH_SIZE)
    post_batch(batch, function(ok)
        self.sending = false
        if ok then
            print("[analytics] 上报成功:", #batch, "条")
        else
            -- 失败:把事件塞回队列头部,等待下次重试
            for i = #batch, 1, -1 do
                table.insert(queue.items, 1, batch[i])
            end
            queue.persist()
        end
    end)
end

踩坑:http.request 是异步的,回调可能在任意帧触发。不要在回调里直接操作 GUI 节点,先 msg.post 给对应脚本,让它在自己的上下文里处理。

批量策略:数量触发(攒够 20 条)、时间触发(每 10 秒)、混合(先满足者触发)、关键事件插队立即单独发。


4. 崩溃捕获与错误处理

1. sys.set_error_handler

Defold 允许注册全局错误处理器,捕获 Lua 运行时错误:

function init(self)
    sys.set_error_handler(function(source, message, traceback)
        -- source: 出错的脚本或模块路径
        -- message: 错误描述
        -- traceback: 调用栈字符串
        print("[crash] source:", tostring(source))
        print("[crash] message:", tostring(message))
        report_crash(source, message, traceback)
    end)
end

关键点:注册后引擎不会因为 Lua 错误直接崩溃退出,这给了你上报的时间窗口。

2. 崩溃上报的三段式

1. 捕获     → sys.set_error_handler 拿到 source/message/traceback
2. 持久化   → 立即写入本地文件(网络此时往往不可靠)
3. 延迟上报 → 下次启动时检查待发送崩溃报告并上传
local CRASH_FILE = "crash_pending"

local function report_crash(source, message, traceback)
    local payload = {
        source    = tostring(source),
        message   = tostring(message),
        traceback = tostring(traceback),
        ts        = os.time(),
        app       = sys.get_application_info(),
        device    = sys.get_sys_info(),
        engine    = sys.get_engine_info(),
    }
    sys.save(CRASH_FILE, payload)   -- 同步落盘,确保退出前写入
    print("[crash] 崩溃报告已写入本地")
end

踩坑:sys.save 在崩溃回调里必须是同步的。不要在里面发网络请求——进程随时可能被系统回收,异步回调没机会执行。

3. 启动时补报与原生崩溃

function init(self)
    local pending = sys.load("crash_pending")
    if pending and pending.message then
        post_crash(pending, function(ok)
            if ok then
                sys.save("crash_pending", {})   -- 上报成功才清空
                print("[crash] 历史崩溃补报成功")
            end
        end)
    end
    sys.set_error_handler(on_error)
end

Lua 层的 set_error_handler 只能捕获脚本错误。引擎层崩溃(段错误、OOM、显卡驱动问题)需要原生扩展支持:Sentry 扩展(社区维护的 defold-sentry)、Firebase Crashlytics(通过原生扩展接入),或自建符号化(收集 tombstone 与堆栈,服务端符号化)。


5. 错误堆栈与上下文采集

1. 手动采集堆栈

local function capture_context(extra)
    local info = sys.get_sys_info()
    local app  = sys.get_application_info()
    return {
        platform     = info.system_name,
        os_version   = info.system_version,
        device       = info.device_model,
        manufacturer = info.manufacturer,
        language     = info.language,
        app_id       = app.identifier,
        version      = app.version,
        engine       = sys.get_engine_info().version,
        extra        = extra or {},
        trace        = debug.traceback("", 2),
    }
end

字段说明:

API返回内容
sys.get_sys_info系统名、版本、机型、语言、网络类型
sys.get_application_info应用标识、版本号
sys.get_engine_info引擎版本、编译时间
debug.traceback当前调用栈字符串

2. 业务上下文与面包屑

光有堆栈不够定位问题,还要带上业务状态:

function analytics.set_context(key, value)
    analytics.context[key] = value
end

-- 进入关卡时记录
function on_level_start(self, level_id)
    analytics.set_context("level", level_id)
    analytics.set_context("player_hp", self.hp)
end

-- 面包屑:记录崩溃前最后 N 条操作
local MAX_BREADCRUMBS = 30
function analytics.add_breadcrumb(action, detail)
    table.insert(analytics.breadcrumbs, { action = action, detail = detail, ts = os.time() })
    while #analytics.breadcrumbs > MAX_BREADCRUMBS do
        table.remove(analytics.breadcrumbs, 1)
    end
end

崩溃时把 context 与 breadcrumbs 一并上报,就能看到「玩家在第 7 关、血量 12、背包 8 件、刚打开商店时崩了」。


6. 会话管理与用户标识

1. 会话定义与生命周期回调

冷启动 → 新会话
切后台超过 30 分钟再回来 → 新会话
切后台 30 分钟内回来 → 延续当前会话
local SESSION_TIMEOUT = 30 * 60     -- 秒

function analytics.check_session()
    local now = os.time()
    if not analytics.session_id or (now - analytics.last_active) > SESSION_TIMEOUT then
        analytics.session_id = generate_session_id()
        print("[analytics] 新会话:", analytics.session_id)
    end
    analytics.last_active = now
end

function on_message(self, message_id, message, sender)
    if message_id == hash("window_focus_lost") then
        analytics.track("app_background")
        analytics.flush_now()          -- 切后台是刷新的最佳时机
    elseif message_id == hash("window_focus_gained") then
        analytics.check_session()
    end
end

说明:window_focus_lost / window_focus_gained 是 Defold 的内置消息,分别在切后台和回前台时触发。切后台时系统通常会给几秒钟时间让你把队列刷出去。

注意:匿名 ID 属于「设备标识」范畴,在 GDPR 下通常仍属个人数据,需要在隐私政策中披露并提供重置入口。生成方式很简单——首次启动时用 string.format 造一个随机串并 sys.save 持久化,之后每次启动读回即可。


7. 隐私合规与数据脱敏

1. 上报前的白名单过滤

不要「先收集再脱敏」,而是只上报白名单字段:

local ALLOWED_PROPS = {
    level = true, level_name = true, duration = true,
    item_id = true, price = true, currency = true,
    result = true, step = true, reason = true,
}

local function sanitize(props)
    local out = {}
    for k, v in pairs(props or {}) do
        if ALLOWED_PROPS[k] then out[k] = v end
    end
    return out
end

2. 禁止上报的内容

禁止:真实姓名、邮箱、手机号、IP 全地址、设备序列号
禁止:玩家输入的聊天内容、自定义昵称、精确地理位置
允许:机型、系统版本、语言、应用版本、匿名 ID

3. 合规开关

function analytics.set_consent(granted)
    analytics.consent = granted
    if not granted then
        queue.items = {}
        sys.save("analytics_pending", {})
        print("[analytics] 用户拒绝数据收集,已清空队列")
    end
end

function analytics.track(name, props)
    if not analytics.consent then return end
    queue.push(build_event(name, sanitize(props)))
end

GDPR/CCPA 要求提供数据删除能力:至少要做到提供「重置匿名 ID」入口切断历史关联、服务端按保留期自动清理(如 14 个月)、提供数据导出接口。


8. 线上监控与告警

1. 关键指标

指标计算方式告警阈值示例
崩溃率崩溃会话数 / 总会话数大于 1%
上报失败率失败请求 / 总请求大于 5%
关卡流失率未完成关卡人数 / 进入人数单关大于 40%
首帧耗时冷启动到首帧的秒数P95 大于 5s
ANR 率无响应次数 / 会话数大于 0.5%

2. 客户端自监控

local stats = { ok = 0, fail = 0 }

function track_result(ok)
    if ok then stats.ok = stats.ok + 1 else stats.fail = stats.fail + 1 end
    local total = stats.ok + stats.fail
    if total >= 20 and (stats.fail / total) > 0.3 then
        print(string.format("[analytics] 上报失败率过高: %.1f%%", stats.fail / total * 100))
    end
end

崩溃聚类:服务端按「错误消息 + 堆栈首行」做指纹聚类,否则一个循环报错会瞬间淹没列表。

指纹 = hash(message + traceback 的前 3 行)

踩坑:性能埋点不要每帧上报,否则监控本身会成为性能瓶颈。


9. 速查表

需求API 或做法备注
注册错误处理sys.set_error_handler(fn)签名 (source, message, traceback)
采集堆栈debug.traceback(msg, level)崩溃时手动调用
系统信息sys.get_sys_info()机型、系统版本、语言
应用信息sys.get_application_info()应用标识与版本
引擎信息sys.get_engine_info()引擎版本、编译时间
HTTP 上报http.request(url, "POST", cb, headers, body)异步,回调任意帧触发
本地落盘sys.save(file, table) / sys.load(file)崩溃路径必须同步写
JSON 序列化json.encode(t) / json.decode(s)属性只放标量
切后台回调window_focus_lost 消息刷新队列的最佳时机
内存占用collectgarbage("count")返回 KB
匿名 ID生成后 sys.save 持久化需提供重置入口

一句话记忆:埋点走「内存队列 + 本地落盘 + 定时批量上报」三段式;崩溃捕获靠 sys.set_error_handler 拿 source/message/traceback,先同步落盘、下次启动补报;上报前按白名单脱敏并尊重用户同意开关;线上用崩溃率、上报失败率、关卡流失率三个指标做告警。


相关阅读

延伸阅读

-- ======================================
-- 完整示例:analytics.lua 埋点模块骨架
-- 放在 /scripts/analytics.lua
-- ======================================

local M = {
    uid = nil, session_id = nil, last_active = 0,
    consent = false, context = {}, pending = {}, sending = false,
}

local ENDPOINT   = "https://api.example.com/v1/events"
local API_KEY    = "your-api-key"
local BATCH_SIZE = 20

local ALLOWED = { level = true, duration = true, result = true, item_id = true, price = true }

local function sanitize(props)
    local out = {}
    for k, v in pairs(props or {}) do
        if ALLOWED[k] then out[k] = v end
    end
    return out
end

function M.init()
    M.uid = string.format("u_%x", os.time())
    M.session_id = string.format("s_%x", math.random(1e8))
    M.consent = sys.get_config_string("analytics_consent", "false") == "true"
    sys.set_error_handler(function(source, message, traceback)
        sys.save("crash_pending", {
            source = tostring(source),
            message = tostring(message),
            traceback = tostring(traceback),
            ctx = M.context,
            sys = sys.get_sys_info(),
        })
        print("[analytics] 崩溃已记录:", tostring(message))
    end)
end

function M.track(name, props, priority)
    if not M.consent then return end
    table.insert(M.pending, {
        name = name, uid = M.uid, session = M.session_id,
        ts = os.time(), priority = priority or "normal",
        props = sanitize(props), ctx = M.context,
    })
    if #M.pending > 500 then table.remove(M.pending, 1) end
end

function M.flush(on_done)
    if M.sending or #M.pending == 0 then
        if on_done then on_done(false) end
        return
    end
    M.sending = true
    local batch = {}
    for i = 1, math.min(BATCH_SIZE, #M.pending) do
        table.insert(batch, M.pending[i])
    end
    for _ = 1, #batch do table.remove(M.pending, 1) end

    local headers = { ["Content-Type"] = "application/json", ["X-Api-Key"] = API_KEY }
    http.request(ENDPOINT, "POST", function(self, id, response)
        M.sending = false
        if response.status == 200 then
            print("[analytics] 上报成功:", #batch)
        else
            for i = #batch, 1, -1 do
                table.insert(M.pending, 1, batch[i])
            end
            sys.save("analytics_pending", M.pending)
        end
        if on_done then on_done(response.status == 200) end
    end, headers, json.encode({ events = batch }))
end

return M

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 测试与持续集成
  2. Defold 本地化与多语言
  3. Defold 团队协作与版本控制