mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 06:43:12 +00:00
feat(observability): add Sentry error reporting to Lambdas (#241)
Unhandled errors and timeout warnings go to Sentry when SENTRY_DSN is set; Slack and 3CX secrets are stripped before send.
This commit is contained in:
parent
b048bfeaa1
commit
6eb2e52a74
12 changed files with 324 additions and 1 deletions
|
|
@ -27,6 +27,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import ThreeCXClient
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import os
|
||||||
|
|
||||||
from slack_sdk import WebClient
|
from slack_sdk import WebClient
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.blocks import build_release_announcement_blocks
|
from shared.blocks import build_release_announcement_blocks
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.ring_scheduler import update_queue_routing
|
from shared.ring_scheduler import update_queue_routing
|
||||||
from shared.schedule import (
|
from shared.schedule import (
|
||||||
FALLBACK_EXTENSION,
|
FALLBACK_EXTENSION,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from shared.three_cx_client import ThreeCXClient
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.schedule import ShiftSchedule
|
from shared.schedule import ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
from shared.three_cx_client import ThreeCXClient
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
boto3>=1.43.78
|
boto3>=1.43.78
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
sentry-sdk==2.68.1
|
||||||
|
|
||||||
|
|
|
||||||
138
src/shared/shared/sentry_init.py
Normal file
138
src/shared/shared/sentry_init.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Shared Sentry SDK init for every afterhours-shift-manager Lambda.
|
||||||
|
|
||||||
|
Imported for side effect from each handler. ``init_sentry()`` is a no-op when
|
||||||
|
``SENTRY_DSN`` is unset so pytest, local invokes, and a missing SAM parameter
|
||||||
|
never talk to Sentry. ``before_send`` strips auth and Slack signing headers,
|
||||||
|
drops request/extra keys that can hold Slack payloads or 3CX credential
|
||||||
|
material, and removes exception stack-frame locals.
|
||||||
|
``include_local_variables=False`` keeps those locals out of the event in the
|
||||||
|
first place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
|
||||||
|
|
||||||
|
_HEADER_DROP_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"authorization",
|
||||||
|
"x-auth-token",
|
||||||
|
"cookie",
|
||||||
|
"x-amz-security-token",
|
||||||
|
"x-slack-signature",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_DROP_REQUEST_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"body",
|
||||||
|
"Body",
|
||||||
|
"data",
|
||||||
|
"cookies",
|
||||||
|
"raw_email",
|
||||||
|
"prompt",
|
||||||
|
"secret",
|
||||||
|
"SecretString",
|
||||||
|
"hmac",
|
||||||
|
"keys",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_DROP_EXTRA_NEEDLES = (
|
||||||
|
"body",
|
||||||
|
"email",
|
||||||
|
"prompt",
|
||||||
|
"secret",
|
||||||
|
"hmac",
|
||||||
|
"token",
|
||||||
|
"mime",
|
||||||
|
"raw_email",
|
||||||
|
"password",
|
||||||
|
"signing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_header(name):
|
||||||
|
lower = str(name).lower()
|
||||||
|
return lower in _HEADER_DROP_NAMES or lower.startswith("x-amz-")
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_headers(headers):
|
||||||
|
if isinstance(headers, dict):
|
||||||
|
return {k: v for k, v in headers.items() if not _drop_header(k)}
|
||||||
|
if isinstance(headers, list):
|
||||||
|
kept = []
|
||||||
|
for pair in headers:
|
||||||
|
if isinstance(pair, (list, tuple)) and pair and _drop_header(pair[0]):
|
||||||
|
continue
|
||||||
|
kept.append(pair)
|
||||||
|
return kept
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _stacktraces(event):
|
||||||
|
traces = []
|
||||||
|
stacktrace = event.get("stacktrace")
|
||||||
|
if isinstance(stacktrace, dict):
|
||||||
|
traces.append(stacktrace)
|
||||||
|
for section in ("exception", "threads"):
|
||||||
|
container = event.get(section)
|
||||||
|
if not isinstance(container, dict):
|
||||||
|
continue
|
||||||
|
values = container.get("values")
|
||||||
|
if not isinstance(values, list):
|
||||||
|
continue
|
||||||
|
for item in values:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
inner = item.get("stacktrace")
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
traces.append(inner)
|
||||||
|
return traces
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_stack_locals(event):
|
||||||
|
"""Drop frame locals. Names like ``raw``/``item`` still hold secrets."""
|
||||||
|
for stacktrace in _stacktraces(event):
|
||||||
|
frames = stacktrace.get("frames")
|
||||||
|
if not isinstance(frames, list):
|
||||||
|
continue
|
||||||
|
for frame in frames:
|
||||||
|
if isinstance(frame, dict):
|
||||||
|
frame.pop("vars", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _before_send(event, _hint):
|
||||||
|
request = event.get("request")
|
||||||
|
if isinstance(request, dict):
|
||||||
|
headers = request.get("headers")
|
||||||
|
if headers is not None:
|
||||||
|
request["headers"] = _scrub_headers(headers)
|
||||||
|
for key in list(request):
|
||||||
|
if key in _DROP_REQUEST_KEYS or str(key).lower() in {"body", "data"}:
|
||||||
|
request.pop(key, None)
|
||||||
|
extra = event.get("extra")
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
for key in list(extra):
|
||||||
|
lower = str(key).lower()
|
||||||
|
if any(needle in lower for needle in _DROP_EXTRA_NEEDLES):
|
||||||
|
extra.pop(key, None)
|
||||||
|
_strip_stack_locals(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def init_sentry():
|
||||||
|
dsn = os.environ.get("SENTRY_DSN")
|
||||||
|
if not dsn:
|
||||||
|
return
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=dsn,
|
||||||
|
integrations=[AwsLambdaIntegration(timeout_warning=True)],
|
||||||
|
send_default_pii=False,
|
||||||
|
include_local_variables=False,
|
||||||
|
enable_logs=False,
|
||||||
|
traces_sample_rate=0.0,
|
||||||
|
before_send=_before_send,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
init_sentry()
|
||||||
|
|
@ -5,6 +5,7 @@ import os
|
||||||
|
|
||||||
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
|
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from zoneinfo import ZoneInfo
|
||||||
import boto3
|
import boto3
|
||||||
from slack_sdk import WebClient
|
from slack_sdk import WebClient
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
||||||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ Parameters:
|
||||||
Type: String
|
Type: String
|
||||||
Default: "801"
|
Default: "801"
|
||||||
Description: 3CX queue extension number to update
|
Description: 3CX queue extension number to update
|
||||||
|
SentryDsn:
|
||||||
|
Type: String
|
||||||
|
Default: ""
|
||||||
|
NoEcho: true
|
||||||
|
Description: Sentry DSN; empty disables error reporting
|
||||||
|
|
||||||
Globals:
|
Globals:
|
||||||
Function:
|
Function:
|
||||||
|
|
@ -22,6 +27,10 @@ Globals:
|
||||||
Architectures:
|
Architectures:
|
||||||
- arm64
|
- arm64
|
||||||
PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary
|
PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
SENTRY_DSN: !Ref SentryDsn
|
||||||
|
|
||||||
# Access logging + default throttling on the implicit HTTP API (audit M-18).
|
# Access logging + default throttling on the implicit HTTP API (audit M-18).
|
||||||
HttpApi:
|
HttpApi:
|
||||||
AccessLogSettings:
|
AccessLogSettings:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ def aws_env(monkeypatch):
|
||||||
monkeypatch.setenv("SHIFT_CHANNEL", "C_TEST")
|
monkeypatch.setenv("SHIFT_CHANNEL", "C_TEST")
|
||||||
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
|
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
|
||||||
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
|
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
|
||||||
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
|
||||||
|
|
||||||
def _create_table(dynamodb):
|
def _create_table(dynamodb):
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,5 @@ pytest>=9.1.1
|
||||||
moto[dynamodb,ses,secretsmanager]>=5.2.2
|
moto[dynamodb,ses,secretsmanager]>=5.2.2
|
||||||
responses>=0.26.2
|
responses>=0.26.2
|
||||||
freezegun>=1.5.5
|
freezegun>=1.5.5
|
||||||
|
sentry-sdk==2.68.1
|
||||||
|
|
||||||
|
|
|
||||||
165
tests/shared/test_sentry_init.py
Normal file
165
tests/shared/test_sentry_init.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""sentry_init: DSN no-op, init options, and before_send scrub."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
|
||||||
|
|
||||||
|
import shared.sentry_init as sentry_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _reexec(monkeypatch, dsn=None):
|
||||||
|
if dsn is None:
|
||||||
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("SENTRY_DSN", dsn)
|
||||||
|
with patch("sentry_sdk.init") as mocked:
|
||||||
|
importlib.reload(sentry_mod)
|
||||||
|
return mocked
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_dsn_does_not_init(monkeypatch):
|
||||||
|
mocked = _reexec(monkeypatch, dsn=None)
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dsn_does_not_init(monkeypatch):
|
||||||
|
mocked = _reexec(monkeypatch, dsn="")
|
||||||
|
mocked.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_dsn_inits_lambda_integration(monkeypatch):
|
||||||
|
mocked = _reexec(monkeypatch, dsn="https://key@o1.ingest.sentry.io/1")
|
||||||
|
mocked.assert_called_once()
|
||||||
|
kwargs = mocked.call_args.kwargs
|
||||||
|
assert kwargs["dsn"] == "https://key@o1.ingest.sentry.io/1"
|
||||||
|
assert kwargs["send_default_pii"] is False
|
||||||
|
assert kwargs["include_local_variables"] is False
|
||||||
|
assert kwargs["enable_logs"] is False
|
||||||
|
assert kwargs["traces_sample_rate"] == 0.0
|
||||||
|
assert kwargs["before_send"] is sentry_mod._before_send
|
||||||
|
integrations = kwargs["integrations"]
|
||||||
|
assert len(integrations) == 1
|
||||||
|
assert isinstance(integrations[0], AwsLambdaIntegration)
|
||||||
|
assert integrations[0].timeout_warning is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_strips_auth_and_sigv4_headers():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer secret",
|
||||||
|
"X-Auth-Token": "tok",
|
||||||
|
"X-Amz-Date": "20260101T000000Z",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"url": "https://example.invalid/oncall",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert out["request"]["headers"] == {"Content-Type": "application/json"}
|
||||||
|
assert out["request"]["url"] == "https://example.invalid/oncall"
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_strips_slack_signature_header():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"headers": {
|
||||||
|
"X-Slack-Signature": "v0=abc",
|
||||||
|
"X-Slack-Request-Timestamp": "123",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert out["request"]["headers"] == {
|
||||||
|
"X-Slack-Request-Timestamp": "123",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_strips_list_headers():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"headers": [
|
||||||
|
("Authorization", "Bearer secret"),
|
||||||
|
("X-Slack-Signature", "v0=abc"),
|
||||||
|
("Content-Type", "application/json"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert out["request"]["headers"] == [("Content-Type", "application/json")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_drops_body_and_secret_keys():
|
||||||
|
event = {
|
||||||
|
"request": {
|
||||||
|
"body": "token=xoxb-secret&command=/oncall",
|
||||||
|
"data": {"signing_secret": "abc"},
|
||||||
|
"method": "POST",
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"slack_signing_secret": "abc",
|
||||||
|
"tcx_password": "hunter2",
|
||||||
|
"bot_token": "xoxb-secret",
|
||||||
|
"hmac_secret": "aabbcc",
|
||||||
|
"shift_date": "2026-08-29",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert "body" not in out["request"]
|
||||||
|
assert "data" not in out["request"]
|
||||||
|
assert out["request"]["method"] == "POST"
|
||||||
|
assert "slack_signing_secret" not in out["extra"]
|
||||||
|
assert "tcx_password" not in out["extra"]
|
||||||
|
assert "bot_token" not in out["extra"]
|
||||||
|
assert "hmac_secret" not in out["extra"]
|
||||||
|
assert out["extra"]["shift_date"] == "2026-08-29"
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_send_drops_exception_and_thread_frame_locals():
|
||||||
|
event = {
|
||||||
|
"exception": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"function": "handler",
|
||||||
|
"vars": {
|
||||||
|
"signing_secret": "abc",
|
||||||
|
"SecretString": "aabbcc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"function": "_authenticate_user",
|
||||||
|
"vars": {"password": "hunter2"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stacktrace": {
|
||||||
|
"frames": [{"function": "get_secret", "vars": {"item": {"token": "x"}}}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out = sentry_mod._before_send(event, {})
|
||||||
|
assert "vars" not in out["exception"]["values"][0]["stacktrace"]["frames"][0]
|
||||||
|
assert "vars" not in out["threads"]["values"][0]["stacktrace"]["frames"][0]
|
||||||
|
assert "vars" not in out["stacktrace"]["frames"][0]
|
||||||
|
assert (
|
||||||
|
out["exception"]["values"][0]["stacktrace"]["frames"][0]["function"]
|
||||||
|
== "handler"
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue