飛ばなかった Sentry 通知が、Hub と Client クラスを使ったら飛んだ

Python アプリケーションの監視に Sentry というソフトウェア監視ツールを使用している。

\
もし Sentry の雰囲気や簡単な使い方を知りたければ、以下の記事も参照して欲しい。

https://qiita.com/wasashi/items/1909c967c9dcd8277f64

# 問題
Python SDK の ```capture_message``` や ```capture_exception``` メソッドを実行しても Sentry に通知が飛ばなかった（未処理の例外が発生した場合も同様だが、今回使いたいのは明示的なキャプチャの方だった）。

\
作業環境は以下のような感じだ。
```yaml
python==3.9
Django==4.2.1
djangorestframework==3.14.0
sentry-sdk==1.43.0
```
AWS ECS 上で uWSGI を使って起動している。`wsgi.ini` の一部を以下に示す。

```ini:uwsgi.ini
enable-threads=true
threads=4
```
上記のようにマルチスレッドにしている。


# 試行錯誤
まず、Sentry の WSGI のページに、「uWSGI でスレッドサポートするなら `--enable-threads` と `--py-call-uwsgi-fork-hooks` を ON にしてね」とある。`--py-call-uwsgi-fork-hooks` の方を使用していなかったため試したが、改善しなかった。

```ini
py-call-uwsgi-fork-hooks=true
```

https://docs.sentry.io/platforms/python/integrations/wsgi/


\
`sentry_sdk.init` の DSN の設定や環境変数、SDK のバージョン、ECS のアウトバウンドルールなどを疑ってみたが問題は無さそうだった。

`configure_scope` や `push_scope` でスコープの変更をしてみたが、これもうまくいかない。

その後、軽い気持ちでキャプチャメソッドの直前に `sentry_sdk.init` を試したら通知が飛んだ。

## 複数回の `init` は避けたい
通知を飛ばす手段が得られたのは良かった。

しかし、以下の Github Issue に「設計的にアプリケーションライフサイクルの中で SDK を初期化できるのは１度だけ。複数回やると、未定義の振る舞いにつながる」と書かれているのを見つけた。今回の件に直接は関係あるかわからなかったが、`sentry_sdk.init` の使用は避けたかった。

https://github.com/getsentry/sentry-python/issues/3059

## `sentry-sdk` 読み
それで初めて、`capture_message` の実装を追ってみた。

```python:sentry-python/sentry-sdk/api.py
@hubmethod
def capture_message(
    message,  # type: str
    level=None,  # type: Optional[LogLevelStr]
    scope=None,  # type: Optional[Any]
    **scope_kwargs  # type: Any
):
    # type: (...) -> Optional[str]
    return Hub.current.capture_message(message, level, scope=scope, **scope_kwargs)
```

https://github.com/getsentry/sentry-python/blob/1.43.0/sentry_sdk/api.py#L92-L100

```Hub``` というのは SDK のコンカレンシー管理をラップしていて、各スレッド毎に持っているらしい。```Hub.current``` は `Hub` の現在のインスタンスを返す。

https://github.com/getsentry/sentry-python/blob/1.43.0/sentry_sdk/hub.py#L196-L706

で、そいつが呼んでる `capture_message` がこれ。

```python:sentry-python/sentry-sdk/hub.py
def capture_message(self, message, level=None, scope=None, **scope_kwargs):
    """
    :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.Client.capture_event`).
    """
    client, top_scope = self._stack[-1]
    if client is None:
        return None

    last_event_id = top_scope.capture_message(
        message, level=level, client=client, scope=scope, **scope_kwargs
    )

    if last_event_id is not None:
        self._last_event_id = last_event_id

    return last_event_id
```
※ docstring は一部割愛させていただいた。

https://github.com/getsentry/sentry-python/blob/1.43.0/sentry_sdk/hub.py#L338-L369

SDK がイベントを送信することにしたら、`event_id` が返ってくる、と。
そこで以下のコードで確認したところ、`event_id` の値は `None` だった。

```python
from sentry_sdk import capture_message

event_id = capture_message("capture message!")
```

`hub.py` から、`client` が空なのでは？と考えた。

# たどり着いた解決方法

`sentry_sdk` には `Client` というクラスがある。`Client` は、イベントをキャプチャして Sentry へ転送する責任を持つ。

https://github.com/getsentry/sentry-python/blob/1.43.0/sentry_sdk/client.py#L173-L775

`Client` を初期化し、これを引数として新しい `Hub` オブジェクトを作る。その `Hub` から `capture_message` する。

```python
import os
from sentry_sdk import Hub, Client

client = Client(
    dsn=os.environ.get('SENTRY_DSN')
)
hub = Hub(client)
hub.capture_message('capture message!')
```

すると、Sentry に通知が飛んでくれた。

# 感想
とりあえず通知は動くようになった。
しかし対応方法に根拠が無くてもやもやしている。

「`Hub` はスレッド毎にある」とか「[Python SDK はスレッドローカルを使用している](https://docs.sentry.io/platforms/python/troubleshooting/#context-variables-vs-thread-locals)」という情報から、「マルチスレッドが問題なのかな」とフワッと理解している。

`Client` のドキュメントが見当たらなかったが、どこかにあるのだろうか。
`Scope` については、基本的に SDK が自動で管理するため、開発者はトップレベルの API を使用することが推奨されている。`Client` も同じでは？と思わなくはない。

https://docs.sentry.io/platforms/python/enriching-events/scopes/

コードではなく設定で解決できるのでは？と未だに疑っている。
今回使用した Sentry SDK のバージョンは 1 系だった。2 系にバージョンアップしたうえで、マルチスレッドの設定を適切に行えば直ったりしないかは気になる。