Day 070 — 2026-10-10 | ミドルウェア設計パターン: Day 069 の Adapt を Recover・Observe と積んだら壊れた——途中で切れた CSV が完全な 200 で届く・相手が切った要求が成功に数えられる・エラーログが 2 倍、の 5 バグを 8 本のテストで捕まえる

2026-10-10 🟡 上級者 / Phase 3 デバッグ・コードレビュー ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン
Day 069 の次回予告どおり「デバッグ」。Day 069 で作った Adapt(error を返すハンドラ → http.Handler)は単体では 13 本のテストを通った。今日はそれを Day 064 の Recover・Day 063 の Observe・「誰のリクエストか」を添える中間件と本番と同じ順に積んだときに出るバグを探す。Adapt 自体は正しいまま渡す(触らない)。

📚 背景知識(読んでから問題へ)

Day 069 の Adapt は、ハンドラが 200 を送り始めた後に error を返すと panic(http.ErrAbortHandler) で接続を切りました。途中で切れた CSV を、クライアントに「完全な 200」として保存させないためです。

この panic は意図した切断です。ところが外側には「panic を見たら 500 にする」Recover や、「最後に何番だったかを数える」Observe がいます。外側の部品は、この panic が事故なのか意図なのかを知りません。部品をそれぞれ単体でテストしても、積んだときの約束(誰が何を 1 回だけやるか) は誰のテストにも入っていません。今日のバグは全部そこにあります。

積み方は本番と同じです(外から順に)。

Observe(集計)→ Recover(panic → 500)→ Adapt(error → 番号、Day 069)→ AnnotateE(user を添える)→ ハンドラ

世界の実物はどうしているか(一次資料・実測)

実物panic(http.ErrAbortHandler) を見たら送信後の本物の panic確認方法
net/http(Go 1.27.1 手元 src/net/http/server.go)conn.serve の recover() が err != ErrAbortHandler のときだけスタックを出す(1936 行)。ErrAbortHandler は「aborts the response to the client」(1905〜1909 行)スタックをログに出して接続を切るソースを読んだ
chi v5.3.2(middleware/recoverer.go、手元のモジュールキャッシュ)if rvr == http.ErrAbortHandler { panic(rvr) }(26〜29 行)。投げ直す。コメントは "we don't recover http.ErrAbortHandler so the response to the client is aborted, this should not be logged"w.WriteHeader(http.StatusInternalServerError)(40 行)。送信済みかを見ないソースを読み、実物を動かして測った(下の実測 3)
Echo の Recover 中間件ErrAbortHandler を投げ直す、と記憶している—今回ソース未取得・記憶のみ
「error は 1 回だけ扱う」Dave Cheney「Don't just check errors, handle them gracefully」(2016、https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully )が「ログに出してから返す」を二重処理として戒めている—今回 URL を開いていない・記憶のみ

chi は ErrAbortHandler の投げ直しはしていますが、送信後の本物の panic では WriteHeader(500) を呼ぶだけです。これが何を起こすかは、今日の実測 3 で chi 本体を動かして確かめます。

📝 問題

状況

CSV エクスポート(/export)を Day 069 の Adapt に載せ替えた翌週、3 件の報告が来ました。

  1. 営業部:「ダウンロードした CSV の最後の行が internal error になっていることがある。ブラウザはエラーを出さなかった」
  2. SRE:「可用性ダッシュボードは 99.0%。でも問い合わせの件数と合わない。モバイルで途中で閉じた人も成功に入っている気がする」
  3. SRE:「エラーログの件数が、ダッシュボードの 5xx 件数のちょうど 2 倍ある」

mw.go に 5 つのバグがあります(types.go・adapt.go は Day 069 のまま正しい)。

課題

  1. go test -race -count=1 ./... の 8 本(T1〜T8)がすべて通るように、mw.go だけを直してください(Stack の積み順は変えない)
  2. 5 つのバグそれぞれについて「どの報告の原因か/どのテストが捕まえるか」を 1 行で
  3. 短く答えてください(自己評価欄へ)
    ・D1: Observe の「切断を数えない」バグ(B4)だけを直しても T5 は通りません。なぜか。何を一緒に直すと通るか
    ・D2: chi v5.3.2 の Recoverer は B1 は踏んでいません。では B2 はどうか。クライアントに何が届くと予想するか(実測 3 で答え合わせ)

準備

mkdir d70 && cd d70                           # 作業ディレクトリを作って入る
printf 'module d70\n\ngo 1.22\n' > go.mod     # モジュールを作る(標準ライブラリだけ)
# 下の types.go・adapt.go・mw.go(バグ入り)・mw_test.go を保存する
go test -count=1 ./... 2>&1 | grep '^--- FAIL'   # バグ入りで落ちるテスト(5 本: T1 T2 T4 T5 T6)
go test -race -count=1 -v ./...               # 修正後: 8 本すべて PASS

ファイル 1: types.go(Day 069 と同じ。変えない)

types.go(47 行)
package main

import (
	"errors"
	"net/http"
)

// HandlerE は Echo(func(c) error)・Caddy(ServeHTTP(w, r, next) error)と同じく
// 「失敗を error で返す」ハンドラ。書けなかった応答を、呼び出し側が代わりに書く。
type HandlerE func(w http.ResponseWriter, r *http.Request) error

// MiddlewareE は Echo の MiddlewareFunc と同じ形。下流の error を見て、足す・変える・返す。
type MiddlewareE func(HandlerE) HandlerE

// ChainE は ms[0] が最も外側になるように h を包む。
func ChainE(h HandlerE, ms ...MiddlewareE) HandlerE {
	for i := len(ms) - 1; i >= 0; i-- {
		h = ms[i](h)
	}
	return h
}

// StatusError は「クライアントに見せてよい」ステータスと文言を持つ error。
// Msg は応答本文に出る。Err は内部の原因で、ログにだけ出る。
type StatusError struct {
	Code int
	Msg  string
	Err  error
}

func (e *StatusError) Error() string {
	if e.Err != nil {
		return e.Msg + ": " + e.Err.Error()
	}
	return e.Msg
}
func (e *StatusError) Unwrap() error { return e.Err }

// ErrNotFound は「対象が無い」を表す番兵。リポジトリ層が返し、HTTP 層が 404 にする。
var ErrNotFound = errors.New("not found")

// StatusClientClosed は nginx の 499 と同じ意味(クライアントが先に切った)。
// 実際には送らない。ログと集計にだけ使う。
const StatusClientClosed = 499

// LogFunc は Adapt が error 1 件につき 1 回だけ呼ぶ。status は「書いた(書くはずだった)」値。
type LogFunc func(r *http.Request, status int, err error)

ファイル 2: adapt.go(Day 069 の模範解答そのまま。変えない)

adapt.go(86 行)
package main

import (
	"context"
	"errors"
	"net/http"
)

// StatusOf は error を HTTP ステータスに直す。上から順に最初に当たったもの。
func StatusOf(r *http.Request, err error) int {
	var se *StatusError
	switch {
	case errors.As(err, &se):
		if se.Code < 400 || se.Code > 599 {
			return http.StatusInternalServerError // 0・200・1000 は「失敗」として扱えない
		}
		return se.Code
	case errors.Is(err, context.Canceled) && r.Context().Err() != nil:
		return StatusClientClosed // 切ったのは相手。中の誰かが cancel しただけなら下の 500
	case errors.Is(err, context.DeadlineExceeded):
		return http.StatusServiceUnavailable
	case errors.Is(err, ErrNotFound):
		return http.StatusNotFound
	}
	return http.StatusInternalServerError
}

// publicMsg は本文に出す文言。5xx と番兵は固定文言で、err の中身は出さない(CWE-209)。
func publicMsg(err error, status int) string {
	var se *StatusError
	if errors.As(err, &se) && se.Msg != "" && status < 500 {
		return se.Msg
	}
	if status >= 500 {
		return "internal error"
	}
	return http.StatusText(status)
}

// commitWriter は「もう送り始めたか」と「何番を送ったか」だけを覚える。リクエストごとに作る。
type commitWriter struct {
	http.ResponseWriter
	status int // 0 = まだ
}

func (w *commitWriter) WriteHeader(code int) {
	if w.status == 0 && (code >= 200 || code == http.StatusSwitchingProtocols) { // 101 以外の 1xx は確定ではない(server.go 1227 行と同じ線引き)
		w.status = code
	}
	w.ResponseWriter.WriteHeader(code)
}

func (w *commitWriter) Write(b []byte) (int, error) {
	if w.status == 0 {
		w.status = http.StatusOK // WriteHeader なしの Write は暗黙の 200
	}
	return w.ResponseWriter.Write(b)
}

// Unwrap があると http.ResponseController が中の Flusher などに届く(Go 1.20+)。
func (w *commitWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

// Adapt は HandlerE を net/http の http.Handler にする。
func Adapt(h HandlerE, log LogFunc) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cw := &commitWriter{ResponseWriter: w}
		err := h(cw, r)
		if err == nil {
			return
		}
		if cw.status != 0 { // 送信後の error。ステータスはもう変えられない
			log(r, cw.status, err)
			if cw.status < 400 {
				// 成功を名乗った途中の本文を「完全な応答」にしない。接続を切る
				panic(http.ErrAbortHandler)
			}
			return // 失敗の応答はもう書かれている(Echo の Committed と同じ扱い)
		}
		status := StatusOf(r, err)
		log(r, status, err)
		if status == StatusClientClosed {
			return // 相手はいない。書かない
		}
		http.Error(cw, publicMsg(err, status), status)
	})
}

ファイル 3: mw.go(バグ入り。ここを直す)

package main

import (
	"fmt"
	"net/http"
)

// Record は Observe が 1 リクエストにつき 1 回報告する集計の 1 行。
// Status は「クライアントに送った(送るはずだった)」番号。499 は相手が先に切った。
// Aborted は接続を途中で切った(クライアントは応答を受け取り損ねた)。
type Record struct {
	Status  int
	Aborted bool
}

// Sinks は記録の出口。どれも 1 件につき 1 回だけ呼ばれる約束。
type Sinks struct {
	Err   LogFunc                      // ハンドラが返した error
	Panic func(r *http.Request, p any) // 回収した panic
	Rec   func(Record)                 // 集計(SLO・ダッシュボード)
}

// Stack は本番と同じ積み方。外から Observe → Recover → Adapt → AnnotateE → h。
func Stack(h HandlerE, s Sinks) http.Handler {
	return Observe(s.Rec)(Recover(s.Panic)(Adapt(ChainE(h, AnnotateE(s.Err)), s.Err)))
}

// obsWriter は最初に送った番号だけを覚える。
type obsWriter struct {
	http.ResponseWriter
	status int
}

func (o *obsWriter) WriteHeader(c int) {
	if o.status == 0 && c >= 200 {
		o.status = c
	}
	o.ResponseWriter.WriteHeader(c)
}
func (o *obsWriter) Write(b []byte) (int, error) {
	if o.status == 0 {
		o.status = http.StatusOK
	}
	return o.ResponseWriter.Write(b)
}
func (o *obsWriter) Unwrap() http.ResponseWriter { return o.ResponseWriter }

// Observe は 1 リクエストにつき 1 回 Record を報告する。panic が通り抜けても数える(defer)。
func Observe(sink func(Record)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ow := &obsWriter{ResponseWriter: w}
			defer func() {
				st := ow.status
				if st == 0 {
					st = http.StatusOK // 何も書かなければ net/http が 200 を送る
				}
				sink(Record{Status: st})
			}()
			next.ServeHTTP(ow, r)
		})
	}
}

// Recover は panic を回収して 500 を返し、記録する。
func Recover(logPanic func(*http.Request, any)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if p := recover(); p != nil {
					logPanic(r, p)
					http.Error(w, "internal error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

// AnnotateE は失敗に「誰のリクエストか」を添えて記録する(問い合わせ対応のため)。
func AnnotateE(log LogFunc) MiddlewareE {
	return func(next HandlerE) HandlerE {
		return func(w http.ResponseWriter, r *http.Request) error {
			err := next(w, r)
			if err != nil {
				log(r, StatusOf(r, err), fmt.Errorf("user=%s: %w", r.Header.Get("X-User"), err))
			}
			return err
		}
	}
}

ファイル 4: mw_test.go(変えない。T1〜T8 とベンチマーク)

mw_test.go 全体(242 行)
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
)

// sinks は 3 つの出口に来たものを全部覚える(実サーバーのハンドラ goroutine から呼ばれるので Mutex)。
type sinks struct {
	mu     sync.Mutex
	errs   []string
	panics []string
	recs   []Record
}

func (s *sinks) S() Sinks {
	return Sinks{
		Err: func(r *http.Request, st int, err error) {
			s.mu.Lock()
			defer s.mu.Unlock()
			s.errs = append(s.errs, fmt.Sprintf("%d %v", st, err))
		},
		Panic: func(r *http.Request, p any) {
			s.mu.Lock()
			defer s.mu.Unlock()
			s.panics = append(s.panics, fmt.Sprint(p))
		},
		Rec: func(rc Record) {
			s.mu.Lock()
			defer s.mu.Unlock()
			s.recs = append(s.recs, rc)
		},
	}
}

func (s *sinks) snap() ([]string, []string, []Record) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]string(nil), s.errs...), append([]string(nil), s.panics...), append([]Record(nil), s.recs...)
}

// serve は実サーバー(実 TCP)に 1 回投げ、ステータス・本文・読み取りエラーを返す。
// srv.Close はハンドラの終了を待つので、戻った時点で sinks は出そろっている。
func serve(t *testing.T, h http.Handler, user string) (int, string, error) {
	t.Helper()
	srv := httptest.NewServer(h)
	defer srv.Close()
	req, _ := http.NewRequest("GET", srv.URL, nil)
	req.Header.Set("X-User", user)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return 0, "", err
	}
	defer res.Body.Close()
	b, err := io.ReadAll(res.Body)
	return res.StatusCode, string(b), err
}

// streamThenFail は CSV エクスポートの形: 1 行書いて送り出した後に DB が失敗する。
func streamThenFail(w http.ResponseWriter, r *http.Request) error {
	io.WriteString(w, "id,name\n1,alice\n")
	http.NewResponseController(w).Flush() // ここでステータス 200 と 1 行目が相手に届く(chunked)
	return errors.New("rows.Next: driver: bad connection")
}

// T1: 送信後の error。クライアントは「完全な 200」を受け取ってはいけない(接続が切れる)。
// Adapt の panic(http.ErrAbortHandler) は意図した切断なので、panic としては記録しない。
func TestT1_StreamErrorReachesClientAsFailure(t *testing.T) {
	s := &sinks{}
	code, body, err := serve(t, Stack(streamThenFail, s.S()), "alice")
	_, panics, _ := s.snap()
	t.Logf("T1: status=%d body=%q readErr=%v panics=%q", code, body, err, panics)
	if !errors.Is(err, io.ErrUnexpectedEOF) {
		t.Fatalf("読み取りが %v(本文 %q)。途中の CSV が成功として保存される", err, body)
	}
	if len(panics) != 0 {
		t.Fatalf("意図した切断が panic として記録された: %q", panics)
	}
}

// T2: 送信後の本物の panic(nil map 等)。これもクライアントに完全な 200 を渡さない。panic の記録は 1 回。
func TestT2_PanicAfterCommit(t *testing.T) {
	s := &sinks{}
	h := Stack(func(w http.ResponseWriter, r *http.Request) error {
		io.WriteString(w, "id,name\n1,alice\n")
		http.NewResponseController(w).Flush()
		var m map[string]int
		m["x"]++ // panic: assignment to entry in nil map
		return nil
	}, s.S())
	code, body, err := serve(t, h, "alice")
	_, panics, _ := s.snap()
	t.Logf("T2: status=%d body=%q readErr=%v panics=%d", code, body, err, len(panics))
	if !errors.Is(err, io.ErrUnexpectedEOF) {
		t.Fatalf("読み取りが %v(本文 %q)。panic した応答が成功として届いた", err, body)
	}
	if len(panics) != 1 || !strings.Contains(panics[0], "nil map") {
		t.Fatalf("panic の記録 %q; want 1 件", panics)
	}
}

// T3: 何も送る前の panic は 500 と固定文言。集計も 500。(基準線。最初から通る)
func TestT3_PanicBeforeCommit(t *testing.T) {
	s := &sinks{}
	h := Stack(func(w http.ResponseWriter, r *http.Request) error {
		panic("boom: secret=hunter2")
	}, s.S())
	code, body, err := serve(t, h, "alice")
	_, panics, recs := s.snap()
	if err != nil || code != 500 || body != "internal error\n" || len(panics) != 1 ||
		len(recs) != 1 || recs[0] != (Record{Status: 500}) {
		t.Fatalf("got %d %q err=%v panics=%d recs=%v; want 500 \"internal error\\n\" 1 [{500 false}]", code, body, err, len(panics), recs)
	}
}

// countWriter は WriteHeader と Write が呼ばれたかを数える。
type countWriter struct {
	h            http.Header
	headers, wrs int
}

func (c *countWriter) Header() http.Header         { return c.h }
func (c *countWriter) WriteHeader(int)             { c.headers++ }
func (c *countWriter) Write(b []byte) (int, error) { c.wrs++; return len(b), nil }

// T4: 相手が先に切った(r.Context() が終わっている)。Adapt は何も書かない。集計は 499 で、200 ではない。
func TestT4_ClientGoneIsNot200(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	r := httptest.NewRequest("GET", "/report", nil).WithContext(ctx)
	cw := &countWriter{h: http.Header{}}
	s := &sinks{}
	Stack(func(w http.ResponseWriter, r *http.Request) error {
		return fmt.Errorf("query: %w", r.Context().Err())
	}, s.S()).ServeHTTP(cw, r)
	_, _, recs := s.snap()
	if cw.headers != 0 || cw.wrs != 0 || len(recs) != 1 || recs[0] != (Record{Status: StatusClientClosed}) {
		t.Fatalf("headers=%d writes=%d recs=%v; want 0 0 [{499 false}]", cw.headers, cw.wrs, recs)
	}
}

// T5: 送信後の error で接続を切った応答は、集計で「成功」に数えない(Aborted=true)。
func TestT5_AbortedIsCountedAsAborted(t *testing.T) {
	s := &sinks{}
	serve(t, Stack(streamThenFail, s.S()), "alice")
	_, _, recs := s.snap()
	if len(recs) != 1 || recs[0] != (Record{Status: 200, Aborted: true}) {
		t.Fatalf("recs=%v; want [{200 true}]", recs)
	}
}

// T6: error の記録は 1 件につき 1 回。誰のリクエストかはその 1 回に入っている。番号は 404 のまま。
func TestT6_ErrorLoggedOnceWithUser(t *testing.T) {
	for _, c := range []struct {
		err  error
		code int
	}{
		{fmt.Errorf("repo.Get(42): %w", ErrNotFound), 404},
		{errors.New("pq: connection refused"), 500},
	} {
		s := &sinks{}
		code, _, err := serve(t, Stack(func(w http.ResponseWriter, r *http.Request) error { return c.err }, s.S()), "alice")
		errs, _, recs := s.snap()
		if err != nil || code != c.code || len(errs) != 1 || !strings.Contains(errs[0], "user=alice") ||
			len(recs) != 1 || recs[0].Status != c.code {
			t.Fatalf("err=%v: got %d errs=%q recs=%v; want %d、記録 1 件(user=alice 入り)", c.err, code, errs, recs, c.code)
		}
	}
}

// T7: 成功は素通し。記録なし、集計は 200。(基準線)
func TestT7_SuccessUntouched(t *testing.T) {
	s := &sinks{}
	code, body, err := serve(t, Stack(func(w http.ResponseWriter, r *http.Request) error {
		io.WriteString(w, "ok")
		return nil
	}, s.S()), "alice")
	errs, panics, recs := s.snap()
	if err != nil || code != 200 || body != "ok" || len(errs)+len(panics) != 0 || len(recs) != 1 || recs[0] != (Record{Status: 200}) {
		t.Fatalf("got %d %q err=%v errs=%q panics=%q recs=%v", code, body, err, errs, panics, recs)
	}
}

// T8: 同じハンドラを 200 本同時に。リクエストごとの状態が混ざらない(-race で)。
func TestT8_Concurrent(t *testing.T) {
	s := &sinks{}
	errs := []error{nil, ErrNotFound, &StatusError{Code: 409, Msg: "conflict"}, errors.New("x")}
	want := []int{200, 404, 409, 500}
	h := Stack(func(w http.ResponseWriter, r *http.Request) error {
		i := int(r.URL.Query().Get("i")[0] - '0')
		if errs[i] == nil {
			io.WriteString(w, "ok")
		}
		return errs[i]
	}, s.S())
	var wg sync.WaitGroup
	bad := make(chan string, 200)
	for n := 0; n < 200; n++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, httptest.NewRequest("GET", fmt.Sprintf("/x?i=%d", n%4), nil))
			if rec.Code != want[n%4] {
				bad <- fmt.Sprintf("i=%d got %d", n%4, rec.Code)
			}
		}(n)
	}
	wg.Wait()
	close(bad)
	if n := len(bad); n > 0 {
		t.Fatalf("%d 本が違うステータス(例: %s)", n, <-bad)
	}
	if _, _, recs := s.snap(); len(recs) != 200 {
		t.Fatalf("集計 %d 件; want 200", len(recs))
	}
}

func benchServe(b *testing.B, h http.Handler) {
	r := httptest.NewRequest("GET", "/x", nil)
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		h.ServeHTTP(httptest.NewRecorder(), r)
	}
}

func okHandler(w http.ResponseWriter, r *http.Request) error { io.WriteString(w, "ok"); return nil }

var nop = Sinks{Err: func(*http.Request, int, error) {}, Panic: func(*http.Request, any) {}, Rec: func(Record) {}}

func BenchmarkAdaptOnly(b *testing.B) { benchServe(b, Adapt(okHandler, nop.Err)) }
func BenchmarkStackOK(b *testing.B)   { benchServe(b, Stack(okHandler, nop)) }
func BenchmarkStackNotFound(b *testing.B) {
	benchServe(b, Stack(func(w http.ResponseWriter, r *http.Request) error { return ErrNotFound }, nop))
}

バグ入りのまま実行したときの出力(Go 1.27.1、2026-10-10 実測)

--- FAIL: TestT1_StreamErrorReachesClientAsFailure
    T1: status=200 body="id,name\n1,alice\ninternal error\n" readErr=<nil> panics=["net/http: abort Handler"]
--- FAIL: TestT2_PanicAfterCommit
    T2: status=200 body="id,name\n1,alice\ninternal error\n" readErr=<nil> panics=1
--- FAIL: TestT4_ClientGoneIsNot200
    headers=0 writes=0 recs=[{200 false}]; want 0 0 [{499 false}]
--- FAIL: TestT5_AbortedIsCountedAsAborted
    recs=[{200 false}]; want [{200 true}]
--- FAIL: TestT6_ErrorLoggedOnceWithUser
    got 404 errs=["404 user=alice: repo.Get(42): not found" "404 repo.Get(42): not found"] recs=[{404 false}]
http: superfluous response.WriteHeader call from d70.(*obsWriter).WriteHeader (mw.go:38)

💡 ヒント

ヒント1(方向性)

3 つの部品に 1 つずつ問いを立ててください。Recover:「この panic は事故か、Adapt がわざと投げたものか」「もう 200 を送ったか」。Observe:「何も書かれていないのは、200 だからか、相手がもういないからか」「panic が自分を通り抜けたか」。AnnotateE:「この error を記録するのは誰の仕事か」。

ヒント2(アプローチ)
  • Recover は p == http.ErrAbortHandler なら記録せず panic(p) で投げ直す(chi と同じ)
  • Recover が「送信済み」を知るには、Adapt と同じく w を包む必要がある。adapt.go の commitWriter がそのまま使える(同じパッケージ)
  • 送信後の panic には 500 を書けない。Adapt と同じく panic(http.ErrAbortHandler) に変える
  • Observe の defer の中で recover() すると「通り抜けた」が分かる。数えたら必ず投げ直す(投げ直さないと net/http が応答を正常終了させ、完全な 200 が届く)
  • 相手が切ったかは r.Context().Err() != nil(Day 069 R5 と同じ判断を、Observe でももう一度する)
  • AnnotateE は記録せず、fmt.Errorf("user=%s: %w", …) で包んで返すだけにする。%w でないと StatusOf が番兵を見失う
ヒント3(コード骨格)
// Recover
cw := &commitWriter{ResponseWriter: w} // リクエストごと
defer func() {
	p := recover()
	// nil → return
	// p == http.ErrAbortHandler → panic(p)(記録しない)
	// logPanic(r, p)
	// cw.status != 0 → panic(http.ErrAbortHandler)
	// それ以外 → http.Error(cw, "internal error", 500)
}()
next.ServeHTTP(cw, r)

// Observe の defer
p := recover()
rec := Record{Status: ow.status}
// p != nil → Aborted / Status 0 かつ r.Context().Err() != nil → 499 / Status 0 → 200
sink(rec)
// p != nil → panic(p)

✅ 模範解答

5 つのバグ

#場所バグ報告捕まえるテスト
B1Recoverhttp.ErrAbortHandler も事故として回収し、500 を書く。Adapt の「意図した切断」が打ち消され、internal error が CSV の末尾に足された完全な 200 になる。panic ログにも 1 件入る1T1
B2Recover送信済みかを見ずに http.Error。ハンドラ自身が 200 を送った後に panic しても、同じく完全な 200+internal error1T2
B3Observe何も書かれていなければ常に 200。相手が切って Adapt が何も書かなかった要求(Day 069 の 499)を成功に数える2T4
B4Observedefer で数えているが panic を見ない。Adapt が接続を切った応答を {200, Aborted:false}=成功に数える2T5(B1 か B2 を直した後で)
B5AnnotateE「ログに出してから返す」。Adapt も同じ error を記録するので、error 1 件がログ 2 行になる(片方には user が無い)3T6

修正後の mw.go(109 行。バグ入りとの差分 44 行)

package main

import (
	"fmt"
	"net/http"
)

// Record は Observe が 1 リクエストにつき 1 回報告する集計の 1 行。
// Status は「クライアントに送った(送るはずだった)」番号。499 は相手が先に切った。
// Aborted は接続を途中で切った(クライアントは応答を受け取り損ねた)。
type Record struct {
	Status  int
	Aborted bool
}

// Sinks は記録の出口。どれも 1 件につき 1 回だけ呼ばれる約束。
type Sinks struct {
	Err   LogFunc                      // ハンドラが返した error
	Panic func(r *http.Request, p any) // 回収した panic
	Rec   func(Record)                 // 集計(SLO・ダッシュボード)
}

// Stack は本番と同じ積み方。外から Observe → Recover → Adapt → AnnotateE → h。
func Stack(h HandlerE, s Sinks) http.Handler {
	return Observe(s.Rec)(Recover(s.Panic)(Adapt(ChainE(h, AnnotateE()), s.Err)))
}

// obsWriter は最初に送った番号だけを覚える。
type obsWriter struct {
	http.ResponseWriter
	status int
}

func (o *obsWriter) WriteHeader(c int) {
	if o.status == 0 && c >= 200 {
		o.status = c
	}
	o.ResponseWriter.WriteHeader(c)
}
func (o *obsWriter) Write(b []byte) (int, error) {
	if o.status == 0 {
		o.status = http.StatusOK
	}
	return o.ResponseWriter.Write(b)
}
func (o *obsWriter) Unwrap() http.ResponseWriter { return o.ResponseWriter }

// Observe は 1 リクエストにつき 1 回 Record を報告する。panic が通り抜けても数える(defer)。
func Observe(sink func(Record)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ow := &obsWriter{ResponseWriter: w}
			defer func() {
				p := recover() // 通り抜ける panic を一度止めて「切れた」と数え、必ず投げ直す
				rec := Record{Status: ow.status}
				switch {
				case p != nil:
					rec.Aborted = true // ここまで届く panic は接続の切断(ErrAbortHandler)
				case rec.Status == 0 && r.Context().Err() != nil:
					rec.Status = StatusClientClosed // 相手が先に切った。何も送っていない
				case rec.Status == 0:
					rec.Status = http.StatusOK // 何も書かなければ net/http が 200 を送る
				}
				sink(rec)
				if p != nil {
					panic(p)
				}
			}()
			next.ServeHTTP(ow, r)
		})
	}
}

// Recover は panic を回収して 500 を返し、記録する。
func Recover(logPanic func(*http.Request, any)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			cw := &commitWriter{ResponseWriter: w} // Adapt と同じ見張り役。リクエストごとに作る
			defer func() {
				p := recover()
				if p == nil {
					return
				}
				if p == http.ErrAbortHandler {
					panic(p) // 意図した切断。記録せずに net/http へ渡す(chi Recoverer と同じ)
				}
				logPanic(r, p)
				if cw.status != 0 {
					panic(http.ErrAbortHandler) // もう 200 を送った。500 は書けないので切る
				}
				http.Error(cw, "internal error", http.StatusInternalServerError)
			}()
			next.ServeHTTP(cw, r)
		})
	}
}

// AnnotateE は失敗に「誰のリクエストか」を添えて上へ返す。記録はしない(記録は Adapt の 1 か所)。
func AnnotateE() MiddlewareE {
	return func(next HandlerE) HandlerE {
		return func(w http.ResponseWriter, r *http.Request) error {
			err := next(w, r)
			if err != nil {
				return fmt.Errorf("user=%s: %w", r.Header.Get("X-User"), err) // %w で包むので StatusOf は変わらない
			}
			return nil
		}
	}
}

実行結果(Go 1.27.1、2026-10-10、M1 MacBook Pro 8GB)

$ go test -race -count=1 -v ./... 2>&1 | grep -E '^(---|ok)|T1:|T2:'
    mw_test.go:79: T1: status=200 body="id,name\n1,alice\n" readErr=unexpected EOF panics=[]
--- PASS: TestT1_StreamErrorReachesClientAsFailure (0.00s)
    mw_test.go:100: T2: status=200 body="id,name\n1,alice\n" readErr=unexpected EOF panics=1
--- PASS: TestT2_PanicAfterCommit (0.00s)
--- PASS: TestT3_PanicBeforeCommit (0.00s)
--- PASS: TestT4_ClientGoneIsNot200 (0.00s)
--- PASS: TestT5_AbortedIsCountedAsAborted (0.00s)
--- PASS: TestT6_ErrorLoggedOnceWithUser (0.00s)
--- PASS: TestT7_SuccessUntouched (0.00s)
--- PASS: TestT8_Concurrent (0.01s)
ok  	d70	1.525s

修正後は http: superfluous response.WriteHeader call の警告も出なくなる(バグ入りでは T1・T2 で 1 行ずつ出ていた)。

🪜 Step-by-Step 解説

Step 1: B1——Recover が「意図した切断」を事故として扱う

Adapt は 200 を送った後の error で panic(http.ErrAbortHandler) を投げます。バグ入りの Recover はこれを recover() で受け止め、http.Error(w, "internal error", 500) を書いて正常に戻ります。正常に戻ったハンドラの応答を、net/http は chunked の終端まで送って完了させます。結果、クライアントには id,name\n1,alice\ninternal error\n が読み取りエラーなしの 200 として届きます(実測。報告 1 の正体)。

修正は chi と同じく、ErrAbortHandler なら記録せずに投げ直すことです。panic は Recover を素通りして net/http の conn.serve に届き、そこで接続が切られます(ログも出ない。server.go 1936 行)。

Step 2: B2——送信後の本物の panic も同じ穴に落ちる

B1 を直しても、ハンドラ自身の panic(nil map への代入など)が 200 を送った後に起きると、Recover は同じく http.Error を書いて完全な 200 にします(T2)。Recover は送信済みかを知らないからです。

Recover にも Adapt と同じ見張り役 commitWriter を被せ、送信済みなら panic を記録したうえで panic(http.ErrAbortHandler) に変えます。「記録してから切る」順番に注意してください(切ってからでは記録の行に届かない)。

B1 と B2 は別の修正です。B2 だけ直すと、Adapt の ErrAbortHandler も「送信済みの panic」として切断にはなる(クライアントは正しく失敗を受け取る)が、panic ログに net/http: abort Handler が 1 件入り、T1 が落ちます(実測。意図した切断でアラートが鳴る)。

Step 3: B3——「何も書いていない」には 2 つの意味がある

net/http はハンドラが何も書かずに戻ると 200 を送ります。だからバグ入りの Observe の if st == 0 { st = 200 } は、普段は正しいのです。ところが Day 069 の Adapt は、相手が先に切った(r.Context().Err() != nil)ときにわざと何も書きません(499)。Observe から見ると両者は同じ「0」です。

Observe でも r.Context().Err() を見て 499 に分けます。同じ判断を Adapt と Observe の 2 か所でするのは重複に見えますが、Observe は Adapt を知らない外側の部品なので、番号以外の手がかり(ctx)を自分で見るしかありません(別案は下の「迷った他案」)。

Step 4: B4——通り抜けた panic を数え、必ず投げ直す

Observe の defer は panic が通り抜けても動くので「数え漏れ」はありません(Day 064 B4 の修正済み)。ただし ow.status は 200 のままなので、接続を切った応答を成功として数えます。defer の中で recover() して Aborted: true を付け、数えた後に panic(p) で投げ直します。投げ直しを忘れると(変異 M1)、Observe が panic を飲み込んで正常終了し、B1 と同じ「完全な 200」に戻ります。

B4 は B1 に隠されていた: バグ入りの Recover が ErrAbortHandler を飲み込むので、Observe まで panic が届きません。B4 だけ直しても T5 は落ちたままです(実測 1 の表)。

Step 5: B5——ログに出してから返す

AnnotateE は「問い合わせ対応で誰のリクエストか分かるように」user 付きで記録し、error も上へ返していました。上の Adapt も記録するので、error 1 件がログ 2 行になります(報告 3。片方には user が無い)。

修正は「記録は Adapt の 1 か所」と決め、AnnotateE は情報を error に足して返すだけにすることです。%w で包むので StatusOf の errors.Is(err, ErrNotFound) はそのまま効き、Adapt の 1 行に user=alice が入ります。%v で包むと(変異 M5)番兵が見えなくなり 404 が 500 に、相手の切断も 499 でなく 500 になります(T4・T6・T8 が落ちる)。

📏 実測(予想と外れた所を含む)

1. どのバグを直すと、どのテストが通るか(直した組み合わせ別)

直したバグ落ちるテスト
なしT1 T2 T4 T5 T6
B1 だけT2 T4 T5 T6
B2 だけT1 T4 T5 T6
B3 だけT1 T2 T5 T6
B4 だけT1 T2 T4 T5 T6(何も変わらない)
B5 だけT1 T2 T4 T5
B1+B4T2 T4 T6
B2+B4T1 T4 T6
B1+B2+B3+B5(B4 だけ残す)T5
B2+B3+B4+B5(B1 だけ残す)T1
B1+B3+B4+B5(B2 だけ残す)T2
すべてなし(-race -count=3 も ok)
  • 予想と外れた所: B4 を直せば T5 は通ると思っていた。実際は B1 が Adapt の panic を飲み込むので、B4 の修正は何も変えない。B1 か B2 のどちらかを直すと T5 が通る(B2 だけでも、送信済みの panic を ErrAbortHandler に変えて投げるので Observe まで届く)
  • 「B1 だけ残す」と T1 だけが落ちる。このときクライアントは正しく unexpected EOF を受け取るが、panic ログに net/http: abort Handler が入る(意図した切断で panic アラートが鳴る)

2. 変異 12 通り(修正後の mw.go に 1 か所ずつ入れる。-race 付き)

変異検出したテスト
M1 Observe が panic を投げ直さないT1, T2
M2 Recover が next に(cw でなく)w を渡すT2
M3 Observe の「0 → 200」を 499 判定より先にT4
M4 ErrAbortHandler の判定を logPanic の後にT1
M5 AnnotateE が %v で包むT4, T6, T8
M6 AnnotateE が user を足さずに元の err を返すT6
M7 obsWriter が最後の WriteHeader を覚える生存
M8 obsWriter の Unwrap を消すT1, T2
M9 Recover の commitWriter を合成時に 1 個だけ作るT8(-race)
M10 Observe が 499 に直さず Status 0 のまま報告T4
M11 Recover が送信後でも http.Error を書く(B2 に戻す)T2
M12 Observe が panic 時も Aborted=false(B4 に戻す)T5
  • M7 は生存。修正後の積み方では 2 回目の WriteHeader を呼ぶ部品がもう無いので、「最初の番号を覚える」と「最後の番号を覚える」の差が出ない。ただしバグ入り(B1)のときは Recover の http.Error が 2 回目の WriteHeader(500) を呼んでいたので、M7 の Observe ならクライアントは 200 なのにダッシュボードは 500 という逆向きの食い違いになる(ここは今回テストしていない。推論)。今日のテストは「線の上の番号」と「数えた番号」の一致を、2 回目の WriteHeader がある状況では見張っていない
  • M8(Unwrap なし)は Flush が効かず、row1 が送信されないまま Adapt が切るので、クライアントは本文でなく応答そのものを受け取れない(unexpected EOF ではなく Get ...: EOF)。T1 はこれを別の失敗として捕まえる
  • 最初の採点で M6 が「生存」と出たが、変異の書き方で fmt が未使用になりビルドが失敗していただけだった。書き直して T6 で検出を確認した

3. chi v5.3.2 の Recoverer を実際に動かす(D2 の答え合わせ)

手元のモジュールキャッシュの github.com/go-chi/chi/v5@v5.3.2 を GOPROXY=off で使い、io.WriteString(w, "id,name\n1,alice\n") の後に panic するハンドラを middleware.Recoverer で包んで実 TCP で取得した。

panic の値Flush してから結果(status / 本文 / 読み取りエラー)
nil map への代入する200 / "id,name\n1,alice\n" / なし。サーバーに superfluous response.WriteHeader call from ...Recoverer.func1.1 (recoverer.go:40)
nil map への代入しない200 / "id,name\n1,alice\n" / なし(本文が小さく Content-Length 付きで正常に送られた)
http.ErrAbortHandlerする200 / "id,name\n1,alice\n" / unexpected EOF
http.ErrAbortHandlerしない応答なし / Get "http://127.0.0.1:…": EOF
  • chi は B1 を踏んでいない(投げ直す)が、B2 と同じ形: 送信後の本物の panic で、クライアントは途中の本文を完全な 200 として受け取る。http.Error でなく WriteHeader だけなので internal error は足されない分、壊れたことがもっと見えない
  • 予想と外れた所: 「Flush しない」場合は本文がまだ手元にあるので、chi なら 500 にできると思っていた。実際は Write の時点で 200 が確定しており(Day 069 の commitWriter と同じ考え方を net/http もしている)、WriteHeader(500) は無視されて 200 で完了した
  • 今日の修正版 Recover では、どちらの場合も切断(Flush ありは unexpected EOF、なしは応答なし)になる

4. ダッシュボードの数字がどれだけずれるか(配分は仮定、数え方は実行結果)

1000 リクエストを Stack に流した。配分は仮定(成功 940・相手が切る 30・ストリーム途中の失敗 20・500 が 10)。可用性 = 成功 ÷(全体 − 499)、成功 = 5xx でなく Aborted でもないもの、とした。

集計の中身エラーログ行数可用性
バグ入り{200}: 990、{500}: 1012099.00%(990/1000)
修正後{200}: 940、{200, Aborted}: 20、{499}: 30、{500}: 106096.91%(940/970)
  • 報告 2 の「99.0%」は、相手が切った 30 件と途中で切れた 20 件を成功に数えた結果だった。報告 3 の「ちょうど 2 倍」は B5(120 = 60 × 2)
  • 499 を分母から外すかどうかは SLO の定義次第(下の「迷った他案」)。どちらの定義でも、Aborted を成功に数えないことは変わらない

5. 割り当て(決定的)と時間(参考)

                      バグ入り             修正後
BenchmarkAdaptOnly    1040 B / 11 allocs   1040 B / 11 allocs  (Adapt 単体。比較の基準)
BenchmarkStackOK      1064 B / 12 allocs   1088 B / 13 allocs  (+24 B・+1 alloc = Recover の commitWriter)
BenchmarkStackNotFound 1176 B / 18 allocs  1192 B / 18 allocs

時間は修正後 StackOK 1,503〜2,057 ns、AdaptOnly 1,543〜1,916 ns。計測中にロードアベレージが 2.8 から 17〜21 に上がった(M1 8GB で他の処理が並走)ので、時間は比較に使わない。根拠は割り当ての差(成功時 +1 alloc / 24 B)だけ。

⚖️ 迷った他案と採らなかった理由

論点採った案迷った他案と採らなかった理由前提が変わったら
Recover が送信済みを知る方法Recover も commitWriter で w を包む(+1 alloc)Adapt の commitWriter を ctx で共有: allocs は増えないが、Recover が Adapt の存在を前提にする(Adapt を外したルートで壊れる)。http.ResponseController で調べる: 標準に「送信済みか」を問う API は無いすべてのルートが必ず Adapt を通る構成に固定できるなら、ctx 共有で 1 alloc 減らせる
送信後の panic記録してから panic(http.ErrAbortHandler)chi 型の WriteHeader(500): 実測 3 のとおり完全な 200 が届く。何もしない: 同じ—
Observe の 499 の判断Observe 自身が r.Context().Err() を見るAdapt が 499 を ctx 経由で Observe に知らせる: 判断が 1 か所になるが、Observe と Adapt が互いを知る結合になる。Adapt の無いルート(素の http.Handler)で相手が切った場合を数えられない観測の部品を 1 つの社内パッケージにまとめ、全ルートが同じ入口を通るなら、ctx 経由の方がよい
Observe が panic を見る方法defer 内で recover() して数え、投げ直すRecover の内側に置いて panic を見ない: Recover が 500 を書いた後の番号は見えるが、Recover より外の切断は見えない。順番の議論は Day 065—
誰が error を記録するかAdapt の 1 か所。中間件は %w で情報を足して返すAnnotateE が記録し、Adapt は記録しない: Adapt を通らない経路(素のハンドラ)で記録が抜ける。slog の属性を ctx に積む: 標準的だが今日の LogFunc の形を変える必要がある構造化ログ(slog)に移るなら、user は ctx の属性として 1 回だけ付ける方が自然
499 を SLO の分母に入れるか外す(クライアントの都合)入れる: モバイルの切断が多い日に SLO が落ちる。ただし「遅すぎて相手が諦めた」切断も外れるp99 遅延の SLO を別に持っていないなら、499 の急増を別アラートにする

🧭 設計思想・なぜこう書くのか

  • panic は Go では「通常の失敗の運び方」ではない。だからこそ net/http は「接続を切る」という 1 つの意味だけを http.ErrAbortHandler という番兵の panic に割り当て、conn.serve で特別扱いしています。外側の中間件がこの番兵を握りつぶすと、Go の標準ライブラリが用意した唯一の「途中で失敗を伝える手段」が消えます
  • 小さい部品を組み合わせる Go の流儀には、組み合わせの約束が要る。func(http.Handler) http.Handler は型の上では何でも積めますが、「panic を投げ直す」「1 回だけ記録する」「最初の番号を覚える」は型に出ません。今日のバグはどれも単体テストでは見えず、Stack を通した 8 本で初めて見えました
  • エラーは 1 回だけ扱う: 「記録する」と「返す」はどちらも error の扱いで、両方やると二重になります。Go の %w は「情報を足して返す」を安く書けるので、記録は端(Adapt)の 1 か所に寄せられます

🌐 他言語との比較

言語・FW送信後の例外・失敗二重ログの起きやすさ今日の Go との違い
chi(Go、実測)ErrAbortHandler は投げ直す。本物の panic は WriteHeader(500) で完全な 200Recoverer 自身は 1 回B2 と同形(実測 3)
Express(Node)既定のエラーハンドラは res.headersSent なら接続を閉じる、と公式ガイドにある(記憶・未確認)next(err) 前に console.error する書き方が多い「閉じる」側で、今日の修正と同じ向き
Spring MVC(Java)例外は @ExceptionHandler へ。コミット済みなら応答を変えられない(未確認)フレームワークも例外をログに出すので、自前でも出すと二重になりやすい(記憶)例外は自動で上がるので「投げ直し忘れ」(M1)は起きにくい
Python(Starlette/FastAPI)ストリーミング中の例外は接続切断になる(未確認)——

Go では panic も error も明示的に渡し直さないと止まるので、Java・Python で自動的に起きる「上へ伝わる」を、中間件ごとに panic(p) や return err で書く必要があります。M1(投げ直し忘れ)はこの差から来る Go 特有のバグです。

🏭 実務での使いどころ

  • 既存の chi アプリに Adapt を入れるとき: middleware.Recoverer は B1 は大丈夫だが B2 の形(実測 3)。ストリーミング応答(CSV・JSON Lines・SSE)があるなら、今日の Recover に差し替えるか、送信済みなら panic(http.ErrAbortHandler) を投げる薄い包みを足す
  • ダッシュボードの監査: 「エラーログ件数 ÷ 5xx 件数」が 1 から離れていないかを週次で見る。2 に近ければ二重記録(B5)、1 より小さければ記録漏れ
  • SLO 定義: Observe に Aborted と 499 を別のラベルで出す。Aborted は失敗に数え、499 は分母から外すかどうかをチームで決めて文書に残す
  • コードレビューの観点: recover() を書いた差分には「ErrAbortHandler を投げ直しているか」「送信済みを見ているか」「数えた後に投げ直しているか」の 3 点を必ず聞く

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Recover はすべての panic を 500 にすればよいpanic=事故と思い込むhttp.ErrAbortHandler は意図した切断。投げ直す(B1)
送信後でも http.Error すれば失敗が伝わるステータスが先に送られることを忘れる本文の末尾に internal error が足された完全な 200 になる(B2・実測)
chi の Recoverer を使っていれば安全ErrAbortHandler の扱いだけ見て判断する送信後の本物の panic は完全な 200(実測 3)
何も書いていなければ 200net/http の既定動作だけを見る相手が切った要求も「何も書いていない」。ctx で区別する(B3)
defer で数えれば panic も正しく数えられる「数え漏れ」だけを気にする番号は 200 のまま。panic を見て Aborted を付ける(B4)
recover() したら仕事は終わり例外言語の catch の感覚数えるだけの部品は必ず panic(p) で投げ直す(M1)
ログに出してから返すと安心どこで記録されるか分からない不安二重記録になる。情報は %w で足し、記録は 1 か所(B5)

🚧 扱わなかった範囲と理由

範囲理由
Echo の Recover 中間件手元のモジュールキャッシュに無く、取得しなかった。表の Echo の行は記憶
Dave Cheney の記事本文今回 URL を開いていない。主張は記憶に基づく要約
HTTP/2 での切断の見え方Day 069 と同じく未測定(RST_STREAM になると記憶)
M7(最後の番号を覚える)を捕まえるテスト修正後の積み方では 2 回目の WriteHeader が起きないので、テストを足す対象が無い。バグ入りと組み合わせた挙動は推論のみ
slog・OpenTelemetry での記録今日の LogFunc・Record の形に絞った。属性を ctx に積む設計は次回の候補
Recover より外側(RequestID・Auth など)を含めた積み順Day 065 で決めた順を前提にし、今日は Observe・Recover・Adapt・AnnotateE の 4 段だけを見た
時間の比較計測中にロードアベレージが 17〜21 に上がった。割り当てだけを根拠にした
Express・Spring・FastAPI の挙動「記憶・未確認」と書いた行は、今回ソースも実行も確認していない

➡️ 次のステップ

  • 発展: Recover と Adapt の 2 つの commitWriter を 1 つにまとめる(ctx で共有)と allocs が 1 減るが、Adapt の無いルートで Recover が壊れないことをどうテストするか
  • 次回予告: 同テーマで「設計・アーキテクチャ」——error・panic・集計の 3 つの出口(ログ/メトリクス/トレース)を、どの部品が 1 回だけ記録するかの表として決める。slog の属性・otelhttp の span・Record の対応、499 と Aborted を SLO にどう入れるかを含める

🎯 自己評価

自分の回答(自力で見つけたバグ数 / 最初の修正で通ったテスト数)

D1・D2 の答え

気づき・メモ