Last active
July 7, 2025 12:57
-
-
Save arnm/620b478490449eecd164c5dcd98cd9f3 to your computer and use it in GitHub Desktop.
Neovim CodeCompanion Analytics Extension with Example Config
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- 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, | |
}, | |
} | |
} | |
}, | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
---@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, | |
} |
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
Great code !
Can this be encapsulated into a plugin?