File

mod_sentry/sentry.lib.lua @ 6199:fe8222112cf4

mod_conversejs: Serve base app at / This makes things slightly less awkward for the browser to figure out which URLs belong to a PWA. The app's "start URL" was previously without the '/' and therefore was not considered within the scope of the PWA. Now the canonical app URL will always have a '/'. Prosody/mod_http should take care of redirecting existing links without the trailing / to the new URL. If you have an installation at https://prosody/conversejs then it is now at https://prosody/conversejs/ (the first URL will now redirect to the second URL if you use it). The alternative would be to make the PWA scope include the parent, i.e. the whole of https://prosody/ in this case. This might get messy if other PWAs are provided by the same site or Prosody installation, however.
author Matthew Wild <mwild1@gmail.com>
date Tue, 11 Feb 2025 13:18:38 +0000
parent 4995: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
	module:log("warn", "Unexpected response from server: %d: %s", response.code, response.body);
	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;
};