# HG changeset patch # User Matthew Wild # Date 1607441693 0 # Node ID 2ae71126e379a6814327e0e744189cdf3391916c # Parent 281a864e74729d56b010c51c00582d5fa79ea50c mod_sentry: New module to forward errors to a Sentry server diff -r 281a864e7472 -r 2ae71126e379 mod_sentry/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_sentry/README.md Tue Dec 08 15:34:53 2020 +0000 @@ -0,0 +1,135 @@ +--- +labels: +- 'Stage-Beta' +summary: 'Send errors to a Sentry server' +--- + +# Introduction + +This module forwards select events to a [Sentry](https://sentry.io/) server. + +# Configuration + +There is a single configuration option, `sentry`, which should be a table +containing the following parameters (optional unless otherwise stated): + +`dsn` +: **Required.** The DSN of the project in Sentry. + +`insecure` +: Whether to allow untrusted HTTPS certificates. + +`server_name` +: The name of the current server (defaults to the system hostname). + +`tags` +: An optional table of tags that will be used as the default for all + events from this module. + +`extra` +: An optional table of custom extra data to attach to all events from + this module. + +Example configuration: + +``` +sentry = { + dsn = "https://37iNFnR4tferFhoTPNe8X0@example.com/11"; + tags = { + environment = "prod"; + }; +} +``` + +## Log forwarding + +You can configure log messages to be automatically forwarded to Sentry. +This example will send all "warn" and "error" messages to Sentry, while +sending all "info" and higher messages to syslog: + +``` +log = { + info = "*syslog"; + { levels = "warn", to = "sentry" }; +} +``` + +# Developers + +In addition to the automatic log forwarder, you can integrate Sentry +forwarding directly into modules using the API. + +## API + +Usage example: + +``` +local sentry = module:depends("sentry").new({ + logger = module.name; +}); + +sentry:event("warning") + :message("This is a sample warning") + :send(); +``` + +### Events + +Event objects have a number of methods you can call to add data to them. +All methods return the event itself, which means you can chain multiple +calls together for convenience. + +After attaching all the data you want to include in the event, simply +call `event:send()` to submit it to the server. + +#### set(key, value) + +Directly set a property of the event to the given value. + +#### tag(name, value) + +Set the specified tag to the given value. + +May also be called with a table of key/value pairs. + +#### extra(name, value) + +Sets the specified 'extra' data. May also be called +with a table of key/value pairs. + +#### message(text) + +Sets the message text associated with the event. + +#### set_request(request) + +Sets the HTTP request associated with the event. + +This is used to indicate what incoming HTTP request +was being processed at the time of the event. + +#### add_exception(e) + +Accepts an error object (from util.error or any arbitrary value) +and attempts to map it to a Sentry exception. + +May be called multiple times on the same event, to represent +nested exceptions (the outermost exception should be added first). + +#### add_breadcrumb(timestamp, type, category, message, data) + +Add a breadcrumb to the event. A breadcrumb represents any useful +piece of information that led up to the event. See Sentry documentation +for allowable types and categories. + +#### add_http_request_breadcrumb(request, message) + +Helper to add a breadcrumb representing a HTTP request that was made. + +The `message` parameter is an optional human-readable text description +of the request. + +#### send() + +Sends the event to the Sentry server. Returns a promise that resolves +to the response from the server. diff -r 281a864e7472 -r 2ae71126e379 mod_sentry/mod_sentry.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_sentry/mod_sentry.lua Tue Dec 08 15:34:53 2020 +0000 @@ -0,0 +1,80 @@ +module:set_global(); + +local sentry_lib = module:require "sentry"; + +local hostname; +local have_pposix, pposix = pcall(require, "util.pposix"); +if have_pposix and pposix.uname then + hostname = pposix.uname().nodename; +end + +local loggingmanager = require "core.loggingmanager"; +local format = require "util.format".format; + +local default_config = assert(module:get_option("sentry"), "Please provide a 'sentry' configuration option"); +default_config.server_name = default_config.server_name or hostname or "prosody"; + +local sentry = assert(sentry_lib.new(default_config)); + +local log_filters = { + source = function (filter_source, name) + local source = name:match(":(.+)$") or name; + if filter_source == source then + return true; + end + end; + message_pattern = function (pattern, _, _, message) + return not not message:match(pattern); + end; +}; + +local function sentry_error_handler(e) + module:log("error", "Failed to submit event to sentry: %s", e); +end + +local function sentry_log_sink_maker(sink_config) + local filters = sink_config.ignore or {}; + local n_filters = #filters; + + local submitting; + return function (name, level, message, ...) + -- Ignore any log messages that occur during sentry submission + -- to avoid loops + if submitting then return; end + for i = 1, n_filters do + local filter = filters[i]; + local matched; + for filter_name, filter_value in pairs(filter) do + local f = log_filters[filter_name]; + if f and f(filter_value, name, level, message) then + matched = true; + else + matched = nil; + break; + end + end + if matched then + return; + end + end + if level == "warn" then + level = "warning"; + end + + submitting = true; + sentry:event(level, name):message(format(message, ...)):send():catch(sentry_error_handler); + submitting = false; + end; +end + +loggingmanager.register_sink_type("sentry", sentry_log_sink_maker); + +function new(conf) --luacheck: ignore 131/new + conf = conf or {}; + for k, v in pairs(default_config) do + if conf[k] == nil then + conf[k] = v; + end + end + return sentry_lib.new(conf); +end diff -r 281a864e7472 -r 2ae71126e379 mod_sentry/sentry.lib.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_sentry/sentry.lib.lua Tue Dec 08 15:34:53 2020 +0000 @@ -0,0 +1,251 @@ +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 + data[k] = tostring(v); + end + return data; +end + +local function error_to_sentry_exception(e) + return { + 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); + }; + }; +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_error(e) then + 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; +};