view mod_sentry/sentry.lib.lua @ 4887:806f7c8d830b

mod_ping_muc: Remove 'kick' status code The intent is "you fell off", not "you were kicked out", however older clients may not recognise the 333 code, but that will have to be an acceptable loss.
author Kim Alvefur <zash@zash.se>
date Mon, 07 Feb 2022 16:52:19 +0100
parents c13b8003ee5c
children cb3de818ff55
line wrap: on
line source

local array = require "util.array";
local hex = require "util.hex";
local random = require "util.random";
local url = require "socket.url";
local datetime = require "util.datetime".datetime;
local http = require 'net.http'
local json = require "util.json";
local errors = require "util.error";
local promise = require "util.promise";

local unpack = unpack or table.unpack -- luacheck: ignore

local user_agent = ("prosody-mod-%s/%s"):format((module.name:gsub("%W", "-")), (prosody.version:gsub("[^%w.-]", "-")));

local function generate_event_id()
	return hex.to(random.bytes(16));
end

local function get_endpoint(server, name)
	return ("%s/api/%d/%s/"):format(server.base_uri, server.project_id, name);
end

-- Parse a DSN string
-- https://develop.sentry.dev/sdk/overview/#parsing-the-dsn
local function parse_dsn(dsn_string)
	local parsed = url.parse(dsn_string);
	if not parsed then
		return nil, "unable to parse dsn (url)";
	end
	local path, project_id = parsed.path:match("^(.*)/(%d+)$");
	if not path then
		return nil, "unable to parse dsn (path)";
	end
	local base_uri = url.build({
		scheme = parsed.scheme;
		host = parsed.host;
		port = parsed.port;
		path = path;
	});
	return {
		base_uri = base_uri;
		public_key = parsed.user;
		project_id = project_id;
	};
end

local function get_error_data(instance_id, context)
	local data = {
		instance_id = instance_id;
	};
	for k, v in pairs(context) do
		if k ~= "traceback" then
			data[k] = tostring(v);
		end
	end
	return data;
end

local function error_to_sentry_exception(e)
	local exception = {
		type = e.condition or (e.code and tostring(e.code)) or nil;
		value = e.text or tostring(e);
		context = e.source;
		mechanism = {
			type = "generic";
			description = "Prosody error object";
			synthetic = not not e.context.wrapped_error;
			data = get_error_data(e.instance_id, e.context);
		};
	};
	local traceback = e.context.traceback;
	if traceback and type(traceback) == "table" then
		local frames = array();
		for i = #traceback, 1, -1 do
			local frame = traceback[i];
			table.insert(frames, {
				["function"] = frame.info.name;
				filename = frame.info.short_src;
				lineno = frame.info.currentline;
			});
		end
		exception.stacktrace = {
			frames = frames;
		};
	end
	return exception;
end

local sentry_event_methods = {};
local sentry_event_mt = { __index = sentry_event_methods };

function sentry_event_methods:set(key, value)
	self.event[key] = value;
	return self;
end

function sentry_event_methods:tag(tag_name, tag_value)
	local tags = self.event.tags;
	if not tags then
		tags = {};
		self.event.tags = tags;
	end
	if type(tag_name) == "string" then
		tags[tag_name] = tag_value;
	else
		for k, v in pairs(tag_name) do
			tags[k] = v;
		end
	end
	return self;
end

function sentry_event_methods:extra(key, value)
	local extra = self.event.extra;
	if not extra then
		extra = {};
		self.event.extra = extra;
	end
	if type(key) == "string" then
		extra[key] = tostring(value);
	else
		for k, v in pairs(key) do
			extra[k] = tostring(v);
		end
	end
	return self;
end

function sentry_event_methods:message(text)
	return self:set("message", { formatted = text });
end

function sentry_event_methods:add_exception(e)
	if errors.is_err(e) then
		if not self.event.message then
			if e.text then
				self:message(e.text);
			elseif type(e.context.wrapped_error) == "string" then
				self:message(e.context.wrapped_error);
			end
		end
		e = error_to_sentry_exception(e);
	elseif type(e) ~= "table" or not (e.type and e.value) then
		e = error_to_sentry_exception(errors.coerce(nil, e));
	end

	local exception = self.event.exception;
	if not exception or not exception.values then
		exception = { values = {} };
		self.event.exception = exception;
	end

	table.insert(exception.values, e);

	return self;
end

function sentry_event_methods:add_breadcrumb(crumb_timestamp, crumb_type, crumb_category, message, data)
	local crumbs = self.event.breadcrumbs;
	if not crumbs then
		crumbs = { values = {} };
		self.event.breadcrumbs = crumbs;
	end

	local crumb = {
		timestamp = crumb_timestamp and datetime(crumb_timestamp) or self.timestamp;
		type = crumb_type;
		category = crumb_category;
		message = message;
		data = data;
	};
	table.insert(crumbs.values, crumb);
	return self;
end

function sentry_event_methods:add_http_request_breadcrumb(http_request, message)
	local request_id_message = ("[Request %s]"):format(http_request.id);
	message = message and (request_id_message.." "..message) or request_id_message;
	return self:add_breadcrumb(http_request.time, "http", "net.http", message, {
		url = http_request.url;
		method = http_request.method or "GET";
		status_code = http_request.response and http_request.response.code or nil;
	});
end

function sentry_event_methods:set_request(http_request)
	return self:set("request", {
		method = http_request.method;
		url = url.build(http_request.url);
		headers = http_request.headers;
		env = {
			REMOTE_ADDR = http_request.ip;
		};
	});
end

function sentry_event_methods:send()
	return self.server:send(self.event);
end

local sentry_mt = { }
sentry_mt.__index = sentry_mt

local function new(conf)
	local server = assert(parse_dsn(conf.dsn));
	return setmetatable({
		server = server;
		endpoints = {
			store = get_endpoint(server, "store");
		};
		insecure = conf.insecure;
		tags = conf.tags or nil,
		extra = conf.extra or nil,
		server_name = conf.server_name or "undefined";
		logger = conf.logger;
	}, sentry_mt);
end

local function resolve_sentry_response(response)
	if response.code == 200 and response.body then
		local data = json.decode(response.body);
		return data;
	end
	return promise.reject(response);
end

function sentry_mt:send(event)
	local json_payload = json.encode(event);
	local response_promise, err = self:_request(self.endpoints.store, "application/json", json_payload);

	if not response_promise then
		module:log("warn", "Failed to submit to Sentry: %s %s", err, json);
		return nil, err;
	end

	return response_promise:next(resolve_sentry_response), event.event_id;
end

function sentry_mt:_request(endpoint_url, body_type, body)
	local auth_header = ("Sentry sentry_version=7, sentry_client=%s, sentry_timestamp=%s, sentry_key=%s")
		:format(user_agent, datetime(), self.server.public_key);

	return http.request(endpoint_url, {
		headers = {
			["X-Sentry-Auth"] = auth_header;
			["Content-Type"] = body_type;
			["User-Agent"] = user_agent;
		};
		insecure = self.insecure;
		body = body;
	});
end

function sentry_mt:event(level, source)
	local event = setmetatable({
		server = self;
		event = {
			event_id = generate_event_id();
			timestamp = datetime();
			platform = "lua";
			server_name = self.server_name;
			logger = source or self.logger;
			level = level;
		};
	}, sentry_event_mt);
	if self.tags then
		event:tag(self.tags);
	end
	if self.extra then
		event:extra(self.extra);
	end
	return event;
end

return {
	new = new;
};