ローカルでsentryを動かす

Webアプリケーションやモバイルアプリなどで発生したエラーは、Sentryに集約してエラーの管理を行う事がよくある。Sentryは設定も簡単で無料枠もあるため、とても導入しやすいと思う。

データ量にもよるけれど、検証環境や開発環境でさえSentryにデータを送信してもいいとさえ思える。ただCIや自動テストで出たエラーまではSentryに流したくないという事もあるだろう。そのような時にSentryのダミーサーバを構築する手段について考え、手順を確認する事にした。

セルフホスティング

Sentryはソースコードも公開されており、セルフホスティングもできるし、Dockerイメージも用意されている。だから Dockerを使ってSentry自体をローカル環境でホスティングする事自体、それほど難しくない。

https://github.com/getsentry/self-hosted にはセルフホスティングするためのファイル一式がある。また、この環境を使うためのドキュメントも公開されている。

手順はドキュメントを参照すると良いが、基本的には docker compose を使用した一般的な構築方法だろう。

MITMProxyでsentryのダミーサーバを書く

セルフホスティングの方法で構築すると、多くのコンテナが起動する事になる。関連するミドルウェアやツールなどのソフトウェアだけでも、egos-tech/smtp、memcached、redis、postgres、pgbouncer、kafka、 clickhouse、seaweedfsといったものを使用するし、それに加えてそれらに付随するコマンドやSentry自身のコンテナもいくつか起動する事になる。

自分達の管理するインフラ上でSentryをホスティングして運用していくのであれば、それらは必要だろう。でもテスト用のダミーサーバだけなのであれば、フルスペックの機能は必要ないことも多く、一部の機能がそれっぽく振る舞ってくれれば良いだけなので、正直そんなにたくさんコンテナを起動させたくないという事もある。

そんな時にはMITMProxyで、一部のAPIだけのダミーサーバを実装してしまう方が楽かもしれない。そこで、ここではSentry APIの簡易なダミーサーバを実装してみる。

import json
import os
import re

from mitmproxy import http

ORG = os.environ.get("SENTRY_ORG", "DUMMYORG")
APP = os.environ.get("SENTRY_APP", "DUMMYAPP")
VERSION = os.environ.get("SENTRY_VERSION", "(^/)+")


def request(flow: http.HTTPFlow):
    method = flow.request.method.upper()
    path = flow.request.path

    if method == "GET" and path == "/api/0/":
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "url": "http://localhost:8080",
                    "chunksPerRequest": 10000000,
                    "maxRequestSize": 10000000,
                    "hashAlgorithm": "sha1",
                    "chunkSize": 10000000,
                    "concurrency": 1,
                    "version": "0.0.1-dev1",
                    "dateCreated": "2026-12-31T23:59:59Z",
                    "environment": "staging",
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "POST" and re.fullmatch(rf"/api/0/projects/{ORG}/{APP}/releases/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "url": "http://localhost:8080",
                    "chunksPerRequest": 10000000,
                    "maxRequestSize": 10000000,
                    "hashAlgorithm": "sha1",
                    "chunkSize": 10000000,
                    "concurrency": 1,
                    "version": VERSION,
                    "dateCreated": "2026-12-31T23:59:59Z",
                    "environment": "staging",
                    "enabled": True,
                    "capabilities": { "chunkedUpload": True },
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "GET" and re.fullmatch(f"/api/0/organizations/{ORG}/chunk-upload/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "url": "http://localhost:8080",
                    "chunksPerRequest": 10000000,
                    "maxRequestSize": 10000000,
                    "hashAlgorithm": "sha1",
                    "chunkSize": 10000000,
                    "concurrency": 1,
                    "version": VERSION,
                    "dateCreated": "2026-12-31T23:59:59Z",
                    "environment": "staging",
                    "enabled": True,
                    "capabilities": { "chunkedUpload": True },
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "GET" and re.fullmatch(rf"/api/0/organizations/{ORG}/repos/\?cursor=", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                [
                    {
                        "id": "1",
                        "sha1": "DUMMY",
                        "name": "DUMMY",
                        "size": 1000,
                        "headers": "{\"foo\": \"bar\"}",
                        "provider": {"id": "1", "name": "baz", "url": ""},
                        "status": "ok",
                        "dateCreated": "2026-12-31T23:59:59Z",
                    },
                ]
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "POST" and re.fullmatch(f"/api/0/projects/{ORG}/{APP}/releases/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "url": "http://localhost:8080",
                    "chunksPerRequest": 10000000,
                    "maxRequestSize": 10000000,
                    "hashAlgorithm": "sha1",
                    "chunkSize": 10000000,
                    "concurrency": 1,
                    "version": VERSION,
                    "dateCreated": "2026-12-31T23:59:59Z",
                    "environment": "staging",
                    "enabled": True,
                    "capabilities": { "chunkedUpload": True },
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "GET" and re.fullmatch(f"/api/0/organizations/{ORG}/releases/{VERSION}/previous-with-commits/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "GET" and re.fullmatch(f"/api/0/projects/{APP}/releases/{VERSION}/previous-with-commits/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "POST" and re.fullmatch(rf"/api/0/projects/{ORG}/{APP}/releases/{VERSION}/files/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                [
                    {
                        "id": "1",
                        "sha1": "DUMMY",
                        "name": "DUMMY",
                        "size": 1000,
                        "headers": {},
                    },
                ]
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "POST" and re.fullmatch(rf"/api/0/projects/{ORG}/{APP}/releases/{VERSION}/files/\?cursor=", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                [
                    {
                        "id": "1",
                        "sha1": "DUMMY",
                        "name": "DUMMY",
                        "size": 1000,
                        "headers": {},
                    },
                ]
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "GET" and re.fullmatch(rf"/api/0/projects/{ORG}/{APP}/releases/{VERSION}/files/\?cursor=.*", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                [
                    {
                        "id": "1",
                        "sha1": "DUMMY",
                        "name": "DUMMY",
                        "size": 1000,
                        "headers": {},
                    },
                ]
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "PUT" and re.fullmatch(rf"/api/0/projects/{ORG}/{APP}/releases/{VERSION}/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "version": VERSION,
                    "dateCreated": "2026-12-31T23:59:59Z",
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "POST" and re.fullmatch(rf"/api/0/organizations/{ORG}/releases/{VERSION}/deploys/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "environment": "staging",
                }
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    if method == "PUT" and re.fullmatch(rf"/api/0/organizations/{ORG}/releases/{VERSION}/", path):
        flow.response = http.Response.make(
            200, content=json.dumps(
                {
                    "url": "http://localhost:8080",
                    "chunksPerRequest": 10000000,
                    "maxRequestSize": 10000000,
                    "hashAlgorithm": "sha1",
                    "chunkSize": 10000000,
                    "concurrency": 1,
                    "version": VERSION,
                    "dateCreated": "2026-12-31T23:59:59Z",
                    "environment": "staging",
                    "enabled": True,
                    "capabilities": { "chunkedUpload": True },
                },
            ).encode(),
            headers={"Content-Type": "application/json"})
        return

    flow.response = http.Response.make(
        404, content=b"{\"message\":\"not found\" }", headers={"Content-Type": "application/json"})
    return
mitm_sentry.py

これを起動する。

mitmproxy -s mitm_sentry.py

これであれば、小さなコンテナ1つだけでSentryのダミーサーバにできる。機能は一部しかサポートしていないけれど、必要に応じてAPIエンドポイントの実装を増やしてあげればよいし、MITMProxyはそういったことがとてもやりやすい。