Skip to content

Instantly share code, notes, and snippets.

@arnm
Last active July 7, 2025 12:57
Show Gist options
  • Save arnm/620b478490449eecd164c5dcd98cd9f3 to your computer and use it in GitHub Desktop.
Save arnm/620b478490449eecd164c5dcd98cd9f3 to your computer and use it in GitHub Desktop.
Neovim CodeCompanion Analytics Extension with Example Config
-- example_config.lua
require('codecompanion').setup {
extensions = {
analytics = {
opts = {
keymap = "<leader>aa",
retention_days = 365,
title_formatter = function(name)
return "### " .. name
end,
row_formatter = function(row)
return "- " .. vim.inspect(row)
end,
default_queries = {
-- "event_type",
"adapter_model"
},
queries = {
{
name = "Copilot Premium Requests",
sql = function(dimension)
return string.format([[
SELECT
SUM(
CASE json_extract(payload, '$.adapter.model')
WHEN 'gpt-4.5' THEN 50
WHEN 'gpt-4.1' THEN 0
WHEN 'gpt-4o' THEN 0
WHEN 'claude-sonnet-3.5' THEN 1
WHEN 'claude-sonnet-3.7' THEN 1
WHEN 'claude-sonnet-3.7-thinking' THEN 1.25
WHEN 'claude-sonnet-4' THEN 1
WHEN 'claude-opus' THEN 4
WHEN 'gemini-2.0-flash' THEN 0.25
WHEN 'gemini-2.5-pro' THEN 1
WHEN 'o1' THEN 10
WHEN 'o3' THEN 1
WHEN 'o3-mini' THEN 0.33
WHEN 'o4-mini' THEN 0.33
ELSE 0
END
) AS premium_requests
FROM metrics
WHERE event_type = 'CodeCompanionRequestStarted'
AND json_extract(payload, '$.adapter.name') = 'copilot'
AND json_extract(payload, '$.adapter.model') IN (
'gpt-4.1','gpt-4o','gpt-4.5',
'claude-sonnet-3.5','claude-sonnet-3.7','claude-sonnet-3.7-thinking','claude-sonnet-4','claude-opus',
'gemini-2.0-flash','gemini-2.5-pro','o1','o3','o3-mini','o4-mini'
)
AND %s;
]], dimension.filter)
end,
title_formatter = function(name, dimension)
return string.format("### %s (%s)", name, dimension.label)
end,
row_formatter = function(row, dimension)
return string.format("- **Premium Requests (%s):** %s", dimension.label, row.premium_requests or "0")
end,
},
{
name = "Copilot Premium Requests by Model",
sql = function(dimension)
return string.format([[
SELECT
json_extract(payload, '$.adapter.model') AS model,
SUM(
CASE json_extract(payload, '$.adapter.model')
WHEN 'gpt-4.5' THEN 50
WHEN 'claude-sonnet-3.5' THEN 1
WHEN 'claude-sonnet-3.7' THEN 1
WHEN 'claude-sonnet-3.7-thinking' THEN 1.25
WHEN 'claude-sonnet-4' THEN 1
WHEN 'claude-opus' THEN 4
WHEN 'gemini-2.0-flash' THEN 0.25
WHEN 'gemini-2.5-pro' THEN 1
WHEN 'o1' THEN 10
WHEN 'o3' THEN 1
WHEN 'o3-mini' THEN 0.33
WHEN 'o4-mini' THEN 0.33
ELSE 0
END
) AS premium_requests
FROM metrics
WHERE event_type = 'CodeCompanionRequestStarted'
AND json_extract(payload, '$.adapter.name') = 'copilot'
AND json_extract(payload, '$.adapter.model') IN (
'gpt-4.5',
'claude-sonnet-3.5','claude-sonnet-3.7','claude-sonnet-3.7-thinking','claude-sonnet-4','claude-opus',
'gemini-2.0-flash','gemini-2.5-pro','o1','o3','o3-mini','o4-mini'
)
AND %s
GROUP BY model;
]], dimension.filter)
end,
title_formatter = function(name, dimension)
return string.format("### %s (%s)", name, dimension.label)
end,
row_formatter = function(row, dimension)
return string.format("- `%s`: **%s** premium requests", row.model or "?", row.premium_requests or "0")
end,
},
}
}
},
}
}
local Snacks = require("snacks")
local store = require("codecompanion._extensions.analytics.store")
local M = {
win = nil,
buf = nil,
current_dimension = "monthly"
}
local DIMENSIONS = {
daily = {
label = "Daily",
filter = "date(ts,'unixepoch') = date('now')"
},
weekly = {
label = "Weekly",
filter = "date(ts,'unixepoch') >= date('now','-6 days')"
},
monthly = {
label = "Monthly",
filter = "strftime('%Y-%m', ts,'unixepoch') = strftime('%Y-%m','now')"
}
}
local DEFAULT_QUERIES = {
event_type = {
name = "Event Counts by Type",
sql = function(dimension)
return string.format([[
SELECT event_type, COUNT(*) AS count
FROM metrics
WHERE %s
GROUP BY event_type;
]], dimension.filter)
end,
title_formatter = function(name, dimension)
return "### " .. name .. " [" .. (dimension.label or "") .. "]"
end,
row_formatter = function(row, dimension)
return string.format("- `%s`: **%d**", row.event_type or "?", row.count or 0)
end
},
adapter_model = {
name = "Requests Started by Adapter/Model",
sql = function(dimension)
return string.format([[
SELECT
json_extract(payload, '$.adapter.name') AS adapter_name,
json_extract(payload, '$.adapter.model') AS model,
COUNT(*) AS count
FROM metrics
WHERE event_type = 'CodeCompanionRequestStarted'
AND %s
GROUP BY adapter_name, model;
]], dimension.filter)
end,
title_formatter = function(name, dimension)
return "### " .. name .. " [" .. (dimension.label or "") .. "]"
end,
row_formatter = function(row, dimension)
return string.format("- `%s` / `%s`: **%d**",
row.adapter_name or "?", row.model or "?", row.count or 0)
end
}
}
local function get_or_create_buffer()
if M.buf and vim.api.nvim_buf_is_valid(M.buf) then
return M.buf
end
M.buf = vim.api.nvim_create_buf(false, true)
local buf_opts = {
filetype = "markdown",
buftype = "nofile",
bufhidden = "wipe",
swapfile = false,
modifiable = false
}
for opt, val in pairs(buf_opts) do
vim.bo[M.buf][opt] = val
end
return M.buf
end
local function update_buffer_content(content)
local buf = get_or_create_buffer()
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, content)
vim.bo[buf].modifiable = false
end
local function normalize_lines(lines)
local result = {}
for _, line in ipairs(lines) do
if type(line) == "string" then
for sub in line:gmatch("([^\n]+)") do
table.insert(result, sub)
end
else
table.insert(result, vim.inspect(line))
end
end
return result
end
local function execute_query(query, dimension_entry)
if not store or not store.metrics then
vim.notify("Analytics store not available", vim.log.levels.WARN)
return {}
end
if not store.metrics.sql or type(store.metrics.sql) ~= "function" then
vim.notify("Analytics store SQL method not available", vim.log.levels.WARN)
return {}
end
if type(query.sql) ~= "function" then
vim.notify("Analytics query.sql must be a function(dimension_entry)", vim.log.levels.WARN)
return {}
end
local sql = query.sql(dimension_entry)
local success, result = pcall(store.metrics.sql, store.metrics, sql)
if not success then
local error_msg = type(result) == "string" and result or "Unknown error"
vim.notify("Failed to execute analytics query: " .. error_msg, vim.log.levels.WARN)
return {}
end
if type(result) ~= "table" then
vim.notify("Analytics query returned unexpected type: " .. type(result), vim.log.levels.WARN)
return {}
end
return result
end
local function format_query_section(query, user_config, dimension_entry)
local lines = { "" }
local title_formatter = query.title_formatter or user_config.title_formatter or
function(name, dim) return "### " .. name end
table.insert(lines, title_formatter(query.name, dimension_entry))
local rows = execute_query(query, dimension_entry)
if #rows == 0 then
table.insert(lines, "- _(none)_")
else
local row_formatter = query.row_formatter or user_config.row_formatter or
function(row, dim) return "- " .. vim.inspect(row) end
for _, row in ipairs(rows) do
table.insert(lines, row_formatter(row, dimension_entry))
end
end
return lines
end
local function build_content()
local user_config = (require("codecompanion.config").extensions.analytics or {}).opts or {}
local user_queries = user_config.queries or {}
local lines = {}
local dimension_entry = DIMENSIONS[M.current_dimension] or DIMENSIONS.daily
local default_query_keys = user_config.default_queries or {}
if vim.tbl_isempty(default_query_keys) then
for _, query in pairs(DEFAULT_QUERIES) do
vim.list_extend(lines, format_query_section(query, user_config, dimension_entry))
end
else
for _, key in ipairs(default_query_keys) do
local query = DEFAULT_QUERIES[key]
if query then
vim.list_extend(lines, format_query_section(query, user_config, dimension_entry))
end
end
end
for _, query in ipairs(user_queries) do
vim.list_extend(lines, format_query_section(query, user_config, dimension_entry))
end
return normalize_lines(lines)
end
local function calculate_window_size(content)
local max_width = 0
for _, line in ipairs(content) do
max_width = math.max(max_width, vim.fn.strdisplaywidth(line))
end
return {
width = math.min(max_width + 4, 80),
height = math.min(#content + 2, 28)
}
end
local function get_window_title()
local dimension_entry = DIMENSIONS[M.current_dimension] or DIMENSIONS.daily
return "📈 CodeCompanion Analytics [" .. (dimension_entry.label or M.current_dimension) .. "]"
end
local function create_key_mappings()
local mappings = {
q = function(win) win:close() end,
["<Esc>"] = function(win) win:close() end
}
local keys = { "d", "w", "m" }
local dimensions = { "daily", "weekly", "monthly" }
for i, key in ipairs(keys) do
mappings[key] = function()
M.current_dimension = dimensions[i]
refresh_window()
end
end
return mappings
end
function refresh_window()
if not (M.win and M.win:valid()) then
return
end
local content = build_content()
local size = calculate_window_size(content)
update_buffer_content(content)
M.win:set_title(get_window_title())
M.win:update(size)
M.win:redraw()
end
local function show_window()
local content = build_content()
local size = calculate_window_size(content)
update_buffer_content(content)
if M.win and M.win:valid() then
M.win:set_title(get_window_title())
M.win:update(size)
M.win:show()
return
end
M.win = Snacks.win(vim.tbl_deep_extend("keep", {
title = get_window_title(),
buf = get_or_create_buffer(),
keys = create_key_mappings(),
border = "rounded",
minimal = true,
wo = {
wrap = false,
number = false,
relativenumber = false,
signcolumn = "no",
conceallevel = 2,
concealcursor = "nvc"
},
on_close = function()
M.win = nil
end
}, size))
end
local function toggle_window()
if M.win and M.win:valid() then
M.win:close()
else
show_window()
end
end
local function setup_autocmds(opts)
local group = vim.api.nvim_create_augroup("CodeCompanionEventLogger", { clear = true })
vim.api.nvim_create_autocmd("User", {
pattern = "CodeCompanion*",
group = group,
callback = function(event)
store.add_metric(event)
if opts and opts.debug then
print("Event:", event.match)
end
end
})
local retention_days = (opts and opts.retention_days) or 365
if store.cleanup_metrics then
store.cleanup_metrics(retention_days)
end
vim.api.nvim_create_user_command("CodeCompanionAnalyticsCleanup", function(params)
local days = tonumber(params.args) or retention_days
store.cleanup_metrics(days)
print("CodeCompanion Analytics: cleaned up metrics older than " .. days .. " days.")
end, {
nargs = "?",
desc = "Cleanup CodeCompanion analytics metrics older than N days"
})
end
local function setup_keymaps(opts)
local chat_keymaps = require("codecompanion.config").strategies.chat.keymaps
chat_keymaps.show_events = {
modes = { n = (opts and opts.keymap) or "gA" },
description = "Toggle CodeCompanion Analytics",
callback = toggle_window
}
end
local Extension = {}
function Extension.setup(opts)
setup_autocmds(opts)
setup_keymaps(opts)
end
Extension.exports = {}
return Extension
---@brief [[
--- Analytics store for the CodeCompanion “analytics” extension.
--- Keeps a single Sqlite database in $XDG_DATA_HOME/codecompanion.
---
--- Public exports:
--- store.add_metric(evt) -- raw insert
--- store.metrics:select{where=…} -- simple query
--- store.metrics:sql(sql,?params) -- raw SQL
--- store.cleanup_metrics(retention_days) -- pruning helper
---@brief ]]
local Path = require("plenary.path")
local sqlite = require("sqlite.db")
local tbl = require("sqlite.tbl")
local json_encode = vim.json and vim.json.encode or vim.fn.json_encode
---@type string
local DB_PATH = Path:new(vim.fn.stdpath("data"), "cc_metrics.sqlite"):absolute()
---@class AnalyticsMetricRow
---@field id integer
---@field ts integer
---@field event_type string
---@field year integer
---@field month integer
---@field day integer
---@field hour integer
---@field payload string
---@type sqlite_tbl
local metrics_tbl = tbl("metrics", {
id = true,
ts = { "integer", required = true },
event_type = { "text", required = true },
year = "integer",
month = "integer",
day = "integer",
hour = "integer",
payload = "text",
ensure = true,
})
---@class AnalyticsDB : sqlite_db
---@field metrics sqlite_tbl
local DB = sqlite {
uri = DB_PATH,
metrics = metrics_tbl,
}
-- open once; ignore “already open” errors
pcall(DB.open, DB)
-- Indices speed up larger DBs
DB:eval [[CREATE INDEX IF NOT EXISTS idx_metrics_year ON metrics(year);]]
DB:eval [[CREATE INDEX IF NOT EXISTS idx_metrics_month ON metrics(month);]]
DB:eval [[CREATE INDEX IF NOT EXISTS idx_metrics_day ON metrics(day);]]
DB:eval [[CREATE INDEX IF NOT EXISTS idx_metrics_evt ON metrics(event_type);]]
local Metrics = DB.metrics -- short alias
-- --------------------------------------------------------------------- --
-- helpers
-- --------------------------------------------------------------------- --
local function dims(ts)
local d = os.date("*t", ts)
return d.year, d.month, d.day, d.hour
end
local function strip_functions(t)
if type(t) ~= "table" then return t end
local out = {}
for k, v in pairs(t) do
if type(v) == "function" then
-- skip
elseif type(v) == "table" then
out[k] = strip_functions(v)
else
out[k] = v
end
end
return out
end
-- --------------------------------------------------------------------- --
-- public API
-- --------------------------------------------------------------------- --
---Insert the Neovim <User> autocommand event into the DB.
---@param evt {match:string, data?:table}|table
local function add_metric(evt)
local now = os.time()
local y, m, d, h = dims(now)
Metrics:insert {
ts = now,
event_type = evt.match,
year = y, month = m, day = d, hour = h,
payload = json_encode(strip_functions(evt.data or {})),
}
end
---@class MetricStore
local MetricStore = {}
---`SELECT * FROM metrics WHERE …`
---@param where? table
---@return AnalyticsMetricRow[]
function MetricStore:select(where)
return Metrics:get { where = where }
end
---Run a raw SQL statement.
---@param statement string
---@param params? table
---@return table[] rows
function MetricStore:sql(statement, params)
return DB:eval(statement, params)
end
---Allow fall-through to any sqlite_tbl method (`insert`, `get`, …)
setmetatable(MetricStore, { __index = Metrics })
---Optionally expose a tiny wrapper; fixes the earlier “nil delete” issue.
function MetricStore:delete(where) -- luacheck: ignore 212
return Metrics:remove(where)
end
---Remove rows older than N days (default 365).
---@param retention_days? integer
local function cleanup_metrics(retention_days)
retention_days = retention_days or 365
local cutoff = os.time() - retention_days * 24 * 60 * 60
DB:eval("DELETE FROM metrics WHERE ts < ?", { cutoff })
end
return {
add_metric = add_metric,
metrics = MetricStore,
cleanup_metrics = cleanup_metrics,
}
@jinzhongjia
Copy link

jinzhongjia commented Jun 19, 2025

Great code !
Can this be encapsulated into a plugin?

@arnm
Copy link
Author

arnm commented Jun 19, 2025

Great code ! Can this be encapsulated into a plugin?

jinzhongjia Hey thanks!

Yes of course I have it working locally as a CodeCompanion extension. Someone just needs to package it up and put it in a public repo and it should start working for others.

I guess I could do it if enough people ask me to :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment