フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: ミドルウェア設計パターン
Day 073 の次回予告どおり「設計」。Day 071〜073 の Edge は 1 つのサービスのmainパッケージの中にあった。これを社内の 3 つのサービスで使うedgeパッケージにするとき、利用者が選べる所を減らすほど、Day 063〜073 で直したバグが再発しにくくなる。一方で、選べないと困るサービスも出る。今日はその線を引き、決めた API が本当に間違いを防ぐかを、契約テスト 14 本と変異 21 通りで確かめる。
📚 背景知識(読んでから問題へ)
ここまでの Edge(Day 073 の修正版)
otelhttp(トレースだけ・TraceContext だけ読む・Filter=IsProbe) → Edge(袋・3 出口に 1 回ずつ) → Identify → Recover → Adapt → ハンドラ
↑ main パッケージの中に、公開の名前が 31 個(関数 15・型 7・定数と変数 9)
これを別のサービスにそのまま渡すと、利用者は 31 個の名前を正しい順に組み立てる必要がある。Day 065 では守り 6 つの積む順 720 通りのうち要件を満たすのは 2 通り、Day 066 では 7 段 5,040 通りのうち 3 通りだった。
用語
| 用語 | 意味 |
|---|---|
| 部品箱(toolkit)型 API | 部品を全部公開し、組み立ては利用者に任せる。chi の middleware パッケージがこの形 |
| Config 構造体型 API | 設定を 1 つの構造体で受け、組み立ては部品の側が行う。net/http.Server・slog.HandlerOptions がこの形 |
| 関数オプション型 API | New(opts ...Option)。otelhttp.NewHandler(h, op, opts...)・grpc.NewServer(opts...) がこの形 |
| 契約テスト | パッケージの外(package edge_test)から、公開 API だけを使って「利用者が書けること・書けないこと」を確かめるテスト |
| 起動時に失敗させる(fail fast) | 間違った設定を、最初の要求ではなく New の戻り値の error で返す |
今日使う実物(一次資料。2026-10-14 に手元のモジュールキャッシュと Go 1.27.2 のソースで読んだ)
| 実物 | 今日に関わる所 | 場所 |
|---|---|---|
| otelhttp v0.70.0 の選択肢 | With… で始まる Option が 11 個(TracerProvider・MeterProvider・PublicEndpointFn・Propagators・SpanOptions・Filter・MessageEvents・SpanNameFormatter・ClientTrace・ServerName・MetricAttributesFn) | config.go 77〜200 行 |
| otelhttp の nil の扱い | WithTracerProvider(nil)・WithMeterProvider(nil)・WithPropagators(nil) は if provider != nil で黙って無視され、大域の既定に落ちる | config.go 79 行・89 行・109 行 |
| otelhttp の既定 | 設定しなければ Propagators: otel.GetTextMapPropagator()・MeterProvider: otel.GetMeterProvider()(大域。Day 073 B1 の原因) | config.go 54〜57 行 |
| otelhttp の公開入口 | WithPublicEndpointFn が true なら trace.WithNewRoot() で新しい root を作り、外の span は trace.WithLinks で link にする | handler.go 104〜110 行 |
| SDK の ParentBased | ParentBased(root, WithRemoteParentSampled(s), …) で「上流が記録した親」の扱いも差し替えられる | sdk@v1.45.0/trace/sampling.go 185 行・224 行 |
| ServeMux.Handler | 要求に対して「選ばれるハンドラとパターン」を、実行せずに返す | Go 1.27.2 net/http/server.go 2719 行 |
| context.WithoutCancel | 値は残し、cancel と期限を外す | Go 1.27.2 context/context.go 586 行 |
📝 問題
状況
Day 071〜073 の Edge を、社内の 3 つのサービスで使うことになりました。
| サービス | 入口 | 欲しいもの |
|---|---|---|
S1 書き出し API(Day 073 の /export) | 社内の BFF の後ろ | 上流(BFF)のトレースの続きとして記録したい。比率 10% |
| S2 Webhook 受信 | インターネットから直接 | 外の利用者が送る traceparent・X-Request-Id は信じたくない |
| S3 管理画面+バッチ API | 社内 | SPA のために "/" で全部受ける。探針は /livez(メソッドなしで登録) |
3 チームとも Go は書けますが、Day 063〜073 の経緯は知りません。
課題
- API の形を決める: 部品箱・Config 構造体・関数オプションのどれにするか。理由と、採らなかった案の欠点を書く
- 項目ごとに線を引く: 次の 15 項目それぞれを「必須の引数」「既定あり(変えられる)」「選ばせない(固定)」のどれにするか決める
- Logger・TracerProvider・MeterProvider・sampler・propagator・Baggage・上流を信じるか・request_id の規則・ルート名・探針・積む順・Recover・499 と aborted と SLI の定義・応答後の仕事への ctx・認証の結果(user)の渡し方
- パッケージ
edgeを書き、契約テストで確かめる: テストはpackage edge_testで書き、公開 API だけを使う。少なくとも次を固定する
| 約束 | テスト |
|---|---|
Logger・Tracer・Meter が無ければ、1 回の New で 3 つまとめて error | K1 |
| 止めたいなら noop を明示すれば通る | K2 |
| 親を見ない sampler は起動時に断る。検査の span はエクスポートされない | K3 |
| 信じる設定なら、上流の trace と X-Request-Id を引き継ぐ。baggage は入れない | K4 |
| 既定は信じない: 外の traceparent は link、X-Request-Id は作り直す | K5 |
http.route は mux が選んだパターン。どこにも当たらない要求は unmatched | K6 |
| 探針は mux のパターンで判定し、span もメトリクスも作らず、失敗だけログ | K7 |
明示した探針の間違い(未登録・"/" に落ちる・書式違い)は起動時に error。既定の探針は "/" を飲み込まない | K8 |
Logger() は WithGroup の後でも鍵が最上位 | K9 |
| 応答後の仕事に渡す ctx は、値を残し・cancel を外し・期限を必ず持つ | K10 |
| Recover は外せない。panic は 500・ログ 1 行(user 付き) | K11 |
New の後で呼び出し側がスライスを書き換えても Edge は変わらない | K12 |
| SLI は Day 073 D8 と同じ配分で 0.9 | K13 |
| 外の利用者が flags=01 を付けても、既定では記録量が自分の比率に戻る | K14 |
予想してから解く(自己評価欄へ)
- P1: Day 073 の部品(otelhttp・Edge・Identify・Recover。Adapt は型の都合で必ず最内側)を利用者が並べるとき、4! = 24 通りのうち Day 072・073 のテスト 19 本を全部通すのは何通りか
- P2: パッケージにして mux を中に入れ、ルート名を
mux.Handlerで引くと、1 要求の割り当ては Day 073 の Stack(63 allocs)からいくつ増えるか - P3: 上流を信じる設定で、外の利用者がランダムな trace_id に flags=01 を付けて 2,000 回送ると、比率 10% の
ParentBasedで何件記録されるか。信じない設定ではどうか
💡 ヒント
ヒント1(方向性)
「利用者に選ばせる」ことの値段は、Day 063〜073 のバグ一覧で測れます。各バグについて「この API の形なら、利用者はそのバグを書けるか」を数えてください。書けない形にできたら、その項目は選ばせなくてよい候補です。逆に「選べないと困るサービス」が 3 つの中にあれば、その項目は引数か既定に残します。
ヒント2(アプローチ)
- 形: 積む順を利用者に選ばせないなら、組み立ては部品の側が持つ。そのとき設定は構造体か関数オプションか。Go の構造体はフィールドのゼロ値が既定になる。ゼロ値が「安全な側」になるように名前を選ぶ(Day 066 の Public・NoRateLimit と同じ考え方)
- 大域に落ちない: otelhttp は nil を黙って無視して大域に落ちる(
config.go79 行)。Day 073 B1 はそれだった。nil は error、止めたいなら noop を明示、と分ける - sampler: 利用者の TracerProvider の中身は外から読めない。だが「親が記録した span を渡したら記録するか」は、span を 1 本作ってみれば分かる
- ルート名と探針:
mux.Handler(r)はパターンを返す。探針をパスで受け取り、起動時に mux に引かせてパターンに直せば、Filter と Edge と mux が同じものを見る - 応答後の仕事:
WithoutCancelとWithTimeoutを別々に使わせると、片方を忘れる(Day 073 W2・W3)。1 つの関数にまとめる
ヒント3(コード骨格)
type Config struct {
Logger slog.Handler // 必須
Tracer trace.TracerProvider // 必須(nil は error、止めるなら noop)
Meter metric.MeterProvider // 必須
TrustUpstream bool // ゼロ値=信じない
Probes []string // パス。既定 /healthz・/readyz に足す
}
func New(cfg Config) (*Edge, error) // 検査はここで全部
func (e *Edge) Handler(mux *http.ServeMux) (http.Handler, error) // 積む順は中で固定
func (e *Edge) Logger() *slog.Logger
func E(h HandlerE) http.Handler // error を返すハンドラを mux に登録する口
func SetUser(ctx context.Context, user string) // 認証から袋への口
func Detach(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc)
func SLI(rm metricdata.ResourceMetrics) float64
✅ 模範解答
1. 形: Config 構造体+New(起動時に全部検査)+Handler(mux)
| 案 | 中身 | 採否 |
|---|---|---|
| A 部品箱 | Day 073 の 31 個の名前を公開し、Stack は例として置く | 不採用。積む順 24 通り中 22 通りがコンパイルを通って壊れる(実測。下の「実測 1」) |
| B Config 構造体 | 設定 5 項目を 1 つの構造体で受け、New が検査し、Handler が固定の順で積む | 採用 |
| C 関数オプション | New(opts ...Option)。otelhttp と同じ形 | 不採用。ただし理由は「形」より「既定」。下の「迷った他案」 |
2. 15 項目の線引き
| 項目 | 区分 | 決めたこと | 根拠(どの日のバグを防ぐか) |
|---|---|---|---|
| Logger | 必須 | slog.Handler で受け、Edge が包む。利用者は e.Logger() を使う | Day 072・073 B3(包み忘れ・WithGroup) |
| TracerProvider | 必須 | nil は error。止めるなら trace/noop を明示 | Day 073 B1 と同じ形(黙って大域に落ちる) |
| MeterProvider | 必須 | 同上 | 同上 |
| sampler | 利用者が選ぶ(TracerProvider の中)。ただし「親に従う」ことだけ起動時に検査 | 親が記録した・捨てた span を 1 本ずつ作って確かめる | Day 073 B2 |
| propagator | 選ばせない | TraceContext だけ | Day 073 B1 |
| Baggage | 選ばせない(入れない) | — | Day 073 W1 |
| 上流を信じるか | 既定あり: 信じない。TrustUpstream: true で開ける | 信じないときは otelhttp の公開入口(新しい root+link)にし、X-Request-Id も作り直す | 今日の K14(強制サンプリング) |
| request_id の規則 | 選ばせない | 1〜64 文字・英数と -_.。信じないときは毎回作る | Day 072 |
| ルート名 | 選ばせない | mux が選んだパターン。無ければ unmatched | Day 063(生パスで 1,000 系列)・Day 065 |
| 探針 | 既定あり: /healthz・/readyz。Probes で足す | パスで受け、起動時に mux でパターンに直す | Day 073 B5 |
| 積む順 | 選ばせない | otelhttp → Edge → Recover → mux → E(h) | Day 065・066・今日の実測 1 |
| Recover | 選ばせない(外せない) | — | Day 071 R8(付け忘れ) |
| 499・aborted・SLI | 選ばせない | Day 071〜073 の定義のまま。SLI() を公開 | Day 070・071 |
| 応答後の仕事への ctx | 口を 1 つ用意: Detach(ctx, timeout)。timeout ≤ 0 は panic | WithoutCancel+WithTimeout を 1 回で | Day 073 B4・W2・W3 |
| 認証の結果(user) | 口を 1 つ用意: SetUser(ctx, user) | 認証ミドルウェアは利用者が mux の中に置く(必ず Recover の内側になる) | Day 065(Recover は Auth より外) |
公開する名前は 12 個(トップレベル 10・メソッド 2)。Day 073 の main パッケージの 31 個、otelhttp(go doc -short で 27 行、うち Option 11 個)より少ない。
準備
mkdir d74 && cd d74 && go mod init d74 # 作業ディレクトリとモジュールを作る
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp@v0.70.0 \
go.opentelemetry.io/otel/sdk@v1.45.0 go.opentelemetry.io/otel/sdk/metric@v1.45.0 # OTel を入れる
mkdir edge # パッケージの置き場所
# 下の edge.go・handler.go・log.go・edge_test.go を edge/ に保存する
go test -race -count=1 ./edge/ # 14 本すべて通る
edge/edge.go(326 行)
// Package edge は、HTTP サービスの入口で「1 要求につきログ 1 行・メトリクス 1 点・span 1 本」を書く部品。
// Day 063〜073 で決めたこと(積む順・探針・propagator・sampler・request_id・499・SLI)を、
// 利用者が選び直せない形で 1 つにまとめる。
package edge
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/semconv/v1.43.0/httpconv"
"go.opentelemetry.io/otel/trace"
)
// Config は New に渡す設定。ゼロ値のフィールドが「黙って大域や何もしないものに落ちる」ことは無い。
// Logger・Tracer・Meter は必須。止めたいときは noop を明示して渡す。
type Config struct {
Logger slog.Handler // 必須。Edge がこれを包み、trace_id・span_id・request_id を最上位に足す
Tracer trace.TracerProvider // 必須。sampler は「親に従う」ものであること(New が確かめる)
Meter metric.MeterProvider // 必須
TrustUpstream bool // true で traceparent と X-Request-Id を引き継ぐ。前段が社内の BFF・LB だけのときに限る。ゼロ値は「信じない」
Probes []string // 探針のパス(例 "/livez")。既定の /healthz・/readyz に足す
}
// Edge は New が作る。Handler で mux を包み、Logger で同じ鍵の付くロガーを返す。
type Edge struct {
log *slog.Logger
tp trace.TracerProvider
dur httpconv.ServerRequestDuration
trust bool
probes []string // 利用者が明示したもの(mux に無ければ Handler がエラー)
}
var defaultProbes = []string{"/healthz", "/readyz"}
// 自己点検に使う trace_id。下位 8 バイトを両端にして、比率だけの sampler が親と逆の判断をするようにする。
var (
checkSampledTID = trace.TraceID{0x4b, 0xf9, 0x2f, 0x35, 0x77, 0xb3, 0x4d, 0xa6, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
checkUnsampledTID = trace.TraceID{0x4b, 0xf9, 0x2f, 0x35, 0x77, 0xb3, 0x4d, 0xa6, 0, 0, 0, 0, 0, 0, 0, 1}
checkParentSID = trace.SpanID{0x00, 0xf0, 0x67, 0xaa, 0x0b, 0xa9, 0x02, 0xb7}
)
// New は設定を確かめて Edge を作る。間違いは起動時に error で返す(本番の 1 要求目まで待たない)。
func New(cfg Config) (*Edge, error) {
var errs []error
if cfg.Logger == nil {
errs = append(errs, errors.New("edge: Config.Logger is nil"))
}
if cfg.Tracer == nil {
errs = append(errs, errors.New("edge: Config.Tracer is nil (use trace/noop to disable tracing explicitly)"))
}
if cfg.Meter == nil {
errs = append(errs, errors.New("edge: Config.Meter is nil (use metric/noop to disable metrics explicitly)"))
}
for _, p := range cfg.Probes {
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, " {}") {
errs = append(errs, fmt.Errorf("edge: probe %q must be a plain path like /livez (no method, no wildcard)", p))
}
}
if cfg.Tracer != nil {
if err := checkSampler(cfg.Tracer); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
mp := cfg.Meter
dur, err := httpconv.NewServerRequestDuration(mp.Meter("edge"))
if err != nil {
return nil, err
}
lh := &logHandler{root: cfg.Logger, cur: cfg.Logger}
return &Edge{
log: slog.New(lh), tp: cfg.Tracer, dur: dur, trust: cfg.TrustUpstream,
probes: append([]string(nil), cfg.Probes...), // 呼び出し側のスライスを後で書き換えられても影響しない(Day 067 B2)
}, nil
}
// checkSampler は、上流が記録した親なら記録し、捨てた親なら捨てるかを、span を 2 本作って確かめる。
// span は End しないので、エクスポーターには送られない。
func checkSampler(tp trace.TracerProvider) error {
tr := tp.Tracer("edge/selfcheck")
for _, c := range []struct {
tid trace.TraceID
want bool
}{{checkSampledTID, true}, {checkUnsampledTID, false}} {
var fl trace.TraceFlags
if c.want {
fl = trace.FlagsSampled
}
psc := trace.NewSpanContext(trace.SpanContextConfig{TraceID: c.tid, SpanID: checkParentSID, TraceFlags: fl, Remote: true})
_, sp := tr.Start(trace.ContextWithRemoteSpanContext(context.Background(), psc), "edge.selfcheck")
if got := sp.SpanContext().IsSampled(); got != c.want {
return fmt.Errorf("edge: sampler ignores the parent's decision (parent sampled=%v, got %v); wrap it with sdktrace.ParentBased", c.want, got)
}
}
return nil
}
// Logger は Edge と同じ鍵(trace_id・span_id・request_id)が最上位に付くロガー。ハンドラと goroutine はこれを使う。
func (e *Edge) Logger() *slog.Logger { return e.log }
// Detach は要求より長生きする仕事(応答後の監査など)に渡す ctx を作る。
// 値(span・request_id)は引き継ぎ、要求の cancel は外し、timeout を必ず掛け直す(Day 073 B4・W2・W3)。
func Detach(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
panic("edge.Detach: timeout must be > 0")
}
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
// Handler は mux を包む。積む順は固定: otelhttp(トレースだけ)→ Edge → Recover → mux → E(h)。
// ルート名は mux が実際に選んだパターン(http.route)。探針も mux のパターンで判定する。
func (e *Edge) Handler(mux *http.ServeMux) (http.Handler, error) {
if mux == nil {
return nil, errors.New("edge: mux is nil")
}
probes := map[string]bool{}
resolve := func(path string, explicit bool) error {
_, pat := mux.Handler(&http.Request{Method: http.MethodGet, URL: &url.URL{Path: path}, Header: http.Header{}})
if pathOf(pat) != path { // 未登録、または "/" などの広いパターンに落ちた
if explicit {
return fmt.Errorf("edge: probe %s is not registered as its own pattern (mux chose %q)", path, pat)
}
return nil // 既定の探針は、登録されていなければ使わない
}
probes[pat] = true
return nil
}
for _, p := range defaultProbes {
resolve(p, false)
}
for _, p := range e.probes {
if err := resolve(p, true); err != nil {
return nil, err
}
}
routeOf := func(r *http.Request) string {
if _, pat := mux.Handler(r); pat != "" {
return pat
}
return "unmatched"
}
inner := e.middleware(recoverMW(mux))
opts := []otelhttp.Option{
otelhttp.WithTracerProvider(e.tp),
otelhttp.WithMeterProvider(metricnoop.NewMeterProvider()), // メトリクスは Edge だけが書く(Day 072)
otelhttp.WithPropagators(propagation.TraceContext{}), // 大域に頼らない・Baggage は入れない(Day 073 B1・W1)
otelhttp.WithFilter(func(r *http.Request) bool { return !outcomeFrom(r.Context()).probe }),
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { return outcomeFrom(r.Context()).route }),
}
if !e.trust { // 外の traceparent は親にせず link にする(強制サンプリングと trace_id の詐称を防ぐ)
opts = append(opts, otelhttp.WithPublicEndpointFn(func(*http.Request) bool { return true }))
}
traced := otelhttp.NewHandler(inner, "http.server", opts...)
// 最も外側で袋を作る。ルートと探針の判定はここで 1 回だけ(Filter・span 名・Edge が同じ値を見る=Day 073 B5)。
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := routeOf(r)
o := &outcome{requestID: e.requestID(r), route: route, probe: probes[route]}
w.Header().Set("X-Request-Id", o.requestID)
traced.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), outcomeKey{}, o)))
}), nil
}
// pathOf は "GET /healthz" や "/healthz" からパスを取り出す。
func pathOf(pattern string) string {
if i := strings.IndexByte(pattern, ' '); i >= 0 {
return pattern[i+1:]
}
return pattern
}
func (e *Edge) requestID(r *http.Request) string {
if e.trust {
if v := r.Header.Get("X-Request-Id"); validRequestID(v) {
return v
}
}
var b [8]byte
rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func validRequestID(s string) bool {
if len(s) == 0 || len(s) > 64 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-' || c == '_' || c == '.') {
return false
}
}
return true
}
func errorTypeOf(status int, aborted bool) string {
switch {
case aborted:
return "aborted"
case status >= 500:
return strconv.Itoa(status)
}
return ""
}
func methodOf(m string) httpconv.RequestMethodAttr {
switch m {
case "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH":
return httpconv.RequestMethodAttr(m)
}
return httpconv.RequestMethodOther
}
// middleware は Day 073 修正版の Edge 本体(袋は外側で作り済み)。
func (e *Edge) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
o := outcomeFrom(r.Context())
ow := &commitWriter{ResponseWriter: w}
defer func() {
p := recover()
aborted := p != nil
status := ow.status
if status == 0 && !aborted {
status = http.StatusOK
if r.Context().Err() != nil {
status = statusClientClosed
ow.WriteHeader(status)
}
}
class, et := classOf(status, aborted), errorTypeOf(status, aborted)
d := time.Since(start)
if !o.probe {
attrs := make([]attribute.KeyValue, 0, 3)
attrs = append(attrs, e.dur.AttrRoute(o.route))
if status != 0 {
attrs = append(attrs, e.dur.AttrResponseStatusCode(status))
}
if et != "" {
attrs = append(attrs, e.dur.AttrErrorType(httpconv.ErrorTypeAttr(et)))
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
e.dur.Record(r.Context(), d.Seconds(), methodOf(r.Method), scheme, attrs...)
}
span := trace.SpanFromContext(r.Context())
if et != "" {
span.SetAttributes(attribute.String("error.type", et))
}
if aborted {
span.SetStatus(codes.Error, "aborted")
if status != 0 {
span.SetAttributes(attribute.Int("http.response.status_code", status))
}
}
level := slog.LevelInfo
if class == classServerError || class == classAborted {
level = slog.LevelError
}
if !o.probe || level == slog.LevelError {
la := make([]slog.Attr, 0, 9)
la = append(la,
slog.String("method", r.Method), slog.String("route", o.route),
slog.Int("status", status), slog.String("class", class),
slog.Int64("dur_us", d.Microseconds()), slog.String("user", o.user))
if o.err != nil {
la = append(la, slog.String("err", o.err.Error()))
}
if o.panic != nil {
la = append(la, slog.Any("panic", o.panic), slog.String("stack", string(o.stack)))
}
e.log.LogAttrs(r.Context(), level, "request", la...)
}
if p != nil {
panic(p)
}
}()
next.ServeHTTP(ow, r)
})
}
// SLI は Edge が書いたメトリクスだけから可用性を出す(Day 071〜073 と同じ定義)。
// 499 は分母から外し、error.type が付いた点(5xx と aborted)を失敗に数える。
func SLI(rm metricdata.ResourceMetrics) float64 {
var valid, bad uint64
for _, sm := range rm.ScopeMetrics {
for _, m := range sm.Metrics {
h, ok := m.Data.(metricdata.Histogram[float64])
if m.Name != "http.server.request.duration" || !ok {
continue
}
for _, dp := range h.DataPoints {
if v, ok := dp.Attributes.Value("http.response.status_code"); ok && v.AsInt64() == statusClientClosed {
continue
}
valid += dp.Count
if dp.Attributes.HasValue("error.type") {
bad += dp.Count
}
}
}
}
if valid == 0 {
return 1
}
return float64(valid-bad) / float64(valid)
}
edge/handler.go(188 行。Day 069〜071 の E・袋・Recover をパッケージ内に閉じたもの)
package edge
import (
"context"
"errors"
"net/http"
"runtime/debug"
)
// HandlerE は「失敗を error で返す」ハンドラ(Day 069)。E で包んで mux に登録する。
type HandlerE func(w http.ResponseWriter, r *http.Request) error
// StatusError は「クライアントに見せてよい」ステータスと文言を持つ error。
// Msg は 4xx の応答本文に出る。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 は「対象が無い」を表す番兵。E が 404 にする。
var ErrNotFound = errors.New("not found")
// statusClientClosed は nginx の 499。送らない。ログと集計にだけ使う(公開しない)。
const statusClientClosed = 499
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
}
return se.Code
case errors.Is(err, context.Canceled) && r.Context().Err() != nil:
return statusClientClosed
case errors.Is(err, context.DeadlineExceeded):
return http.StatusServiceUnavailable
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
}
return http.StatusInternalServerError
}
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 は「送り始めたか・何番を送ったか」だけを覚える(Day 069・070・071 の obsWriter と同じもの)。
type commitWriter struct {
http.ResponseWriter
status int
}
func (w *commitWriter) WriteHeader(code int) {
if w.status == 0 && (code >= 200 || code == http.StatusSwitchingProtocols) {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *commitWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(b)
}
func (w *commitWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// E は HandlerE を http.Handler にする(Day 069 の Adapt。error は記録せず袋に書くだけ=Day 071 の ErrInto)。
// Edge の外で使っても動くが、そのときは error がどこにも記録されない。
func E(h HandlerE) 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 {
errInto(r, cw.status, err)
if cw.status < 400 {
panic(http.ErrAbortHandler)
}
return
}
status := statusOf(r, err)
errInto(r, status, err)
if status == statusClientClosed {
return
}
http.Error(cw, publicMsg(err, status), status)
})
}
// ---- 袋(Day 071): 部品は書くだけ、出力は Edge の defer が 1 回だけ ----
type outcome struct {
requestID string
route string // mux が選んだパターン(http.route)。外側で 1 回だけ決める
probe bool
user string
err error
panic any
stack []byte
}
type outcomeKey struct{}
func outcomeFrom(ctx context.Context) *outcome {
o, _ := ctx.Value(outcomeKey{}).(*outcome)
return o
}
func errInto(r *http.Request, _ int, err error) {
if o := outcomeFrom(r.Context()); o != nil && o.err == nil {
o.err = err
}
}
// SetUser は認証ミドルウェアが「誰の要求か」を Edge に知らせる口。Edge の行の user に出る。
// Edge の外(ctx に袋が無い)では何もしない。
func SetUser(ctx context.Context, user string) {
if o := outcomeFrom(ctx); o != nil {
o.user = user
}
}
// recoverMW は Day 070 の Recover。panic を回収して 500、送信後なら切断。中身は袋に書く。
func recoverMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cw := &commitWriter{ResponseWriter: w}
defer func() {
p := recover()
if p == nil {
return
}
if p == http.ErrAbortHandler {
panic(p)
}
if o := outcomeFrom(r.Context()); o != nil && o.panic == nil {
o.panic, o.stack = p, debug.Stack()
}
if cw.status != 0 {
panic(http.ErrAbortHandler)
}
http.Error(cw, "internal error", http.StatusInternalServerError)
}()
next.ServeHTTP(cw, r)
})
}
const (
classOK = "ok"
classClientError = "client_error"
classClientClosed = "client_closed"
classServerError = "server_error"
classAborted = "aborted"
)
func classOf(status int, aborted bool) string {
switch {
case aborted:
return classAborted
case status == statusClientClosed:
return classClientClosed
case status >= 500:
return classServerError
case status >= 400:
return classClientError
}
return classOK
}
edge/log.go(64 行。Day 073 修正版の LogHandler を非公開にしたもの)
package edge
import (
"context"
"log/slog"
"slices"
"go.opentelemetry.io/otel/trace"
)
// logHandler は Day 073 の修正版 LogHandler(WithGroup の後でも trace_id・span_id・request_id は最上位)。
// 公開しない: 利用者は (*Edge).Logger だけを使う。包み忘れ・二重包みを型で起こさないため。
type logHandler struct {
root slog.Handler
cur slog.Handler
replay []func(slog.Handler) slog.Handler
}
func (h *logHandler) Enabled(ctx context.Context, l slog.Level) bool { return h.cur.Enabled(ctx, l) }
func keysInto(ctx context.Context, buf []slog.Attr) []slog.Attr {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
buf = append(buf, slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()))
}
if o := outcomeFrom(ctx); o != nil {
buf = append(buf, slog.String("request_id", o.requestID))
}
return buf
}
func (h *logHandler) Handle(ctx context.Context, rec slog.Record) error {
var arr [3]slog.Attr
ks := keysInto(ctx, arr[:0])
if len(h.replay) == 0 || len(ks) == 0 {
rec.AddAttrs(ks...)
return h.cur.Handle(ctx, rec)
}
hh := h.root.WithAttrs(slices.Clone(ks))
for _, f := range h.replay {
hh = f(hh)
}
return hh.Handle(ctx, rec)
}
func (h *logHandler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
if len(h.replay) == 0 {
r := h.root.WithAttrs(as)
return &logHandler{root: r, cur: r}
}
as = slices.Clone(as)
return &logHandler{root: h.root, cur: h.cur.WithAttrs(as),
replay: append(slices.Clip(h.replay), func(x slog.Handler) slog.Handler { return x.WithAttrs(as) })}
}
func (h *logHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return &logHandler{root: h.root, cur: h.cur.WithGroup(name),
replay: append(slices.Clip(h.replay), func(x slog.Handler) slog.Handler { return x.WithGroup(name) })}
}
edge/edge_test.go(488 行。K1〜K14。package edge_test で公開 API だけを使う)
package edge_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"d74/edge"
"go.opentelemetry.io/otel/baggage"
metricnoop "go.opentelemetry.io/otel/metric/noop"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
tracenoop "go.opentelemetry.io/otel/trace/noop"
)
// このテストは package edge_test(外から見える API だけ)で書く。利用者が書けることだけを確かめるため。
type lockedBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (l *lockedBuf) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuf) lines(t *testing.T) []map[string]any {
t.Helper()
l.mu.Lock()
defer l.mu.Unlock()
var out []map[string]any
for _, s := range strings.Split(strings.TrimSpace(l.b.String()), "\n") {
if s == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(s), &m); err != nil {
t.Fatalf("JSON でない行: %q", s)
}
out = append(out, m)
}
return out
}
type rig struct {
buf *lockedBuf
sr *tracetest.SpanRecorder
rd *sdkmetric.ManualReader
cfg edge.Config
}
func newRig(sampler sdktrace.Sampler) *rig {
g := &rig{buf: &lockedBuf{}, sr: tracetest.NewSpanRecorder(), rd: sdkmetric.NewManualReader()}
opts := []sdktrace.TracerProviderOption{sdktrace.WithSpanProcessor(g.sr)}
if sampler != nil {
opts = append(opts, sdktrace.WithSampler(sampler))
}
g.cfg = edge.Config{
Logger: slog.NewJSONHandler(g.buf, nil),
Tracer: sdktrace.NewTracerProvider(opts...),
Meter: sdkmetric.NewMeterProvider(sdkmetric.WithReader(g.rd)),
}
return g
}
func (g *rig) build(t *testing.T, mux *http.ServeMux) (*edge.Edge, http.Handler) {
t.Helper()
e, err := edge.New(g.cfg)
if err != nil {
t.Fatal(err)
}
h, err := e.Handler(mux)
if err != nil {
t.Fatal(err)
}
return e, h
}
func (g *rig) metrics(t *testing.T) (metricdata.ResourceMetrics, map[string]uint64) {
t.Helper()
var rm metricdata.ResourceMetrics
if err := g.rd.Collect(context.Background(), &rm); err != nil {
t.Fatal(err)
}
byRoute := map[string]uint64{}
for _, sm := range rm.ScopeMetrics {
for _, m := range sm.Metrics {
if h, ok := m.Data.(metricdata.Histogram[float64]); ok {
for _, dp := range h.DataPoints {
v, _ := dp.Attributes.Value("http.route")
byRoute[v.AsString()] += dp.Count
}
}
}
}
return rm, byRoute
}
func do(h http.Handler, method, path string, hdr map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, nil)
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
const (
upSampled = "4bf92f3577b34da6ffffffffffffffff"
upParent = "00f067aa0ba902b7"
)
func okMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /export", edge.E(func(w http.ResponseWriter, r *http.Request) error {
io.WriteString(w, "id,name\n")
return nil
}))
return mux
}
// K1: 必須の 3 つが無ければ、1 回の New でまとめて error(大域や何もしないものに黙って落ちない)。
func TestK1_RequiredFieldsFailFast(t *testing.T) {
_, err := edge.New(edge.Config{})
if err == nil {
t.Fatal("ゼロ値の Config で New が通った")
}
for _, f := range []string{"Logger", "Tracer", "Meter"} {
if !strings.Contains(err.Error(), "Config."+f) {
t.Errorf("error に %s が無い: %v", f, err)
}
}
}
// K2: 止めたいなら noop を明示すれば通る(「黙って止まる」と「明示して止める」を分ける)。
func TestK2_ExplicitNoopAccepted(t *testing.T) {
buf := &lockedBuf{}
e, err := edge.New(edge.Config{Logger: slog.NewJSONHandler(buf, nil),
Tracer: tracenoop.NewTracerProvider(), Meter: metricnoop.NewMeterProvider()})
if err != nil {
t.Fatal(err)
}
h, _ := e.Handler(okMux())
if w := do(h, "GET", "/export", nil); w.Code != 200 || len(buf.lines(t)) != 1 {
t.Fatalf("code=%d lines=%d", w.Code, len(buf.lines(t)))
}
}
// K3: 親を見ない sampler は起動時に断る。自己点検の span はエクスポーターに出ない。
func TestK3_SamplerSelfCheck(t *testing.T) {
cases := []struct {
name string
s sdktrace.Sampler
ok bool
}{
{"SDK 既定", nil, true},
{"ParentBased(Ratio 0.1)", sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)), true},
{"ParentBased(Never)", sdktrace.ParentBased(sdktrace.NeverSample()), true},
{"Ratio 0.1 裸", sdktrace.TraceIDRatioBased(0.1), false},
{"Ratio 0.5 裸", sdktrace.TraceIDRatioBased(0.5), false},
{"Always 裸", sdktrace.AlwaysSample(), false},
{"Never 裸", sdktrace.NeverSample(), false},
// 上流が記録したものをさらに間引く正当な設定も断る(設計上の割り切り。「迷った他案」参照)
{"ParentBased+RemoteSampled を比率に", sdktrace.ParentBased(sdktrace.AlwaysSample(), sdktrace.WithRemoteParentSampled(sdktrace.TraceIDRatioBased(0.5))), false},
}
for _, c := range cases {
g := newRig(c.s)
_, err := edge.New(g.cfg)
if (err == nil) != c.ok {
t.Errorf("%s: err=%v; want ok=%v", c.name, err, c.ok)
}
if err != nil && !strings.Contains(err.Error(), "ParentBased") {
t.Errorf("%s: 直し方が書かれていない: %v", c.name, err)
}
if n := len(g.sr.Ended()); n != 0 {
t.Errorf("%s: 自己点検の span が %d 本エクスポートされた", c.name, n)
}
}
}
// K4: 社内の入口(TrustUpstream=true)は上流の trace と X-Request-Id を引き継ぐ。baggage は入れない。
func TestK4_InternalContinuesUpstream(t *testing.T) {
g := newRig(nil)
g.cfg.TrustUpstream = true
n := -1
mux := http.NewServeMux()
mux.Handle("GET /export", edge.E(func(w http.ResponseWriter, r *http.Request) error {
n = baggage.FromContext(r.Context()).Len()
return nil
}))
_, h := g.build(t, mux)
w := do(h, "GET", "/export", map[string]string{
"traceparent": "00-" + upSampled + "-" + upParent + "-01", "baggage": "role=admin", "X-Request-Id": "bff-123"})
s := g.sr.Ended()
if len(s) != 1 || s[0].SpanContext().TraceID().String() != upSampled || s[0].Parent().SpanID().String() != upParent {
t.Fatalf("spans=%d; want 上流の続き", len(s))
}
if n != 0 || w.Header().Get("X-Request-Id") != "bff-123" {
t.Fatalf("baggage=%d request_id=%q", n, w.Header().Get("X-Request-Id"))
}
}
// K5: 既定(TrustUpstream=false)は、外から来た traceparent を親にせず link にし、X-Request-Id も作り直す。
func TestK5_DefaultDistrustsHeaders(t *testing.T) {
g := newRig(nil)
_, h := g.build(t, okMux())
w := do(h, "GET", "/export", map[string]string{
"traceparent": "00-" + upSampled + "-" + upParent + "-01", "X-Request-Id": "attacker-chosen"})
s := g.sr.Ended()
if len(s) != 1 || s[0].Parent().IsValid() || s[0].SpanContext().TraceID().String() == upSampled {
t.Fatalf("既定で外の trace を親にした")
}
if len(s[0].Links()) != 1 || s[0].Links()[0].SpanContext.TraceID().String() != upSampled {
t.Fatalf("links=%v; want 外の trace への link 1 本", s[0].Links())
}
if id := w.Header().Get("X-Request-Id"); id == "attacker-chosen" || len(id) != 16 {
t.Fatalf("request_id=%q; want 作り直した 16 桁", id)
}
}
// K6: http.route は mux が選んだパターン。生パスを 100 種類送っても系列は 1 つ。どこにも当たらない要求は unmatched。
func TestK6_RouteIsMuxPattern(t *testing.T) {
g := newRig(nil)
mux := http.NewServeMux()
mux.Handle("GET /users/{id}", edge.E(func(w http.ResponseWriter, r *http.Request) error { return nil }))
_, h := g.build(t, mux)
for i := 0; i < 100; i++ {
do(h, "GET", "/users/"+strconv.Itoa(i), nil)
}
do(h, "GET", "/nope", nil)
_, byRoute := g.metrics(t)
if len(byRoute) != 2 || byRoute["GET /users/{id}"] != 100 || byRoute["unmatched"] != 1 {
t.Fatalf("routes=%v", byRoute)
}
}
// K7: 探針は mux のパターンで判定する。span もメトリクスも作らず、失敗だけログに残す。
func TestK7_ProbesByPattern(t *testing.T) {
g := newRig(nil)
g.cfg.Probes = []string{"/livez"}
mux := okMux()
mux.Handle("GET /healthz", edge.E(func(w http.ResponseWriter, r *http.Request) error { return nil }))
mux.Handle("GET /readyz", edge.E(func(w http.ResponseWriter, r *http.Request) error {
return &edge.StatusError{Code: 503, Msg: "not ready", Err: errors.New("warming up")}
}))
mux.Handle("/livez", edge.E(func(w http.ResponseWriter, r *http.Request) error { return nil })) // メソッドなしの登録
_, h := g.build(t, mux)
for _, p := range []string{"/healthz", "/readyz", "/livez"} {
for i := 0; i < 10; i++ {
do(h, "GET", p, nil)
}
}
for i := 0; i < 9; i++ {
do(h, "GET", "/export", nil)
}
do(h, "POST", "/export", nil) // 405(パターン無し=unmatched。利用者の要求として数える)
rm, byRoute := g.metrics(t)
lines := g.buf.lines(t)
if len(byRoute) != 2 || byRoute["GET /export"] != 9 || byRoute["unmatched"] != 1 {
t.Errorf("routes=%v; want GET /export 9・unmatched 1", byRoute)
}
if n := len(g.sr.Ended()); n != 10 {
t.Errorf("span %d 本; want 10", n)
}
if len(lines) != 20 { // export 10+readyz の失敗 10
t.Errorf("ログ %d 行; want 20", len(lines))
}
if got := edge.SLI(rm); got != 1 {
t.Errorf("SLI=%v; want 1(405 はクライアントの誤り)", got)
}
}
// K8: 明示した探針が mux に無い・広いパターンに落ちる・書式が違う、はすべて起動時に error。
// 既定の探針(/healthz・/readyz)は、登録されていなければ黙って使わない("/" に落ちても探針にしない)。
func TestK8_ProbeMistakesAtStartup(t *testing.T) {
g := newRig(nil)
g.cfg.Probes = []string{"GET /livez"}
if _, err := edge.New(g.cfg); err == nil {
t.Error("メソッド付きの探針が通った")
}
g.cfg.Probes = []string{"/livez"}
e, _ := edge.New(g.cfg)
if _, err := e.Handler(okMux()); err == nil || !strings.Contains(err.Error(), "/livez") {
t.Errorf("未登録の探針: err=%v", err)
}
catchAll := http.NewServeMux()
catchAll.Handle("/", edge.E(func(w http.ResponseWriter, r *http.Request) error { return nil }))
if _, err := e.Handler(catchAll); err == nil {
t.Error("\"/\" に落ちる探針が通った")
}
g2 := newRig(nil)
_, h := g2.build(t, catchAll)
do(h, "GET", "/healthz", nil) // "/" が受ける。利用者の要求として数える
if _, byRoute := g2.metrics(t); byRoute["/"] != 1 {
t.Errorf("routes=%v; want \"/\" に 1(既定の探針が \"/\" を飲み込まない)", byRoute)
}
}
// K9: e.Logger() は WithGroup の後でも鍵が最上位。
func TestK9_LoggerKeysTopLevel(t *testing.T) {
g := newRig(nil)
mux := http.NewServeMux()
var e *edge.Edge
mux.Handle("GET /export", edge.E(func(w http.ResponseWriter, r *http.Request) error {
e.Logger().WithGroup("job").InfoContext(r.Context(), "rows", "n", 2)
return nil
}))
e, h := g.build(t, mux)
do(h, "GET", "/export", nil)
lines := g.buf.lines(t)
if len(lines) != 2 || lines[0]["trace_id"] == nil || lines[0]["trace_id"] != lines[1]["trace_id"] ||
lines[0]["request_id"] != lines[1]["request_id"] {
t.Fatalf("lines=%v", lines)
}
if job, _ := lines[0]["job"].(map[string]any); job["trace_id"] != nil || job["n"] != float64(2) {
t.Fatalf("job=%v", job)
}
}
// K10: Detach は値を残し cancel を外し、期限を必ず掛ける(実サーバー)。0 以下は panic。
func TestK10_Detach(t *testing.T) {
for _, c := range []struct {
delay, timeout time.Duration
want string
}{{30 * time.Millisecond, time.Second, "audit"}, {500 * time.Millisecond, 20 * time.Millisecond, "audit failed"}} {
g := newRig(nil)
var wg sync.WaitGroup
var e *edge.Edge
mux := http.NewServeMux()
mux.Handle("GET /export", edge.E(func(w http.ResponseWriter, r *http.Request) error {
ctx, cancel := edge.Detach(r.Context(), c.timeout)
wg.Add(1)
go func() {
defer wg.Done()
defer cancel()
select {
case <-time.After(c.delay):
e.Logger().InfoContext(ctx, "audit")
case <-ctx.Done():
e.Logger().ErrorContext(ctx, "audit failed", "err", ctx.Err().Error())
}
}()
io.WriteString(w, "ok")
return nil
}))
e, h := g.build(t, mux)
srv := httptest.NewServer(h)
resp, err := http.Get(srv.URL + "/export")
if err != nil {
t.Fatal(err)
}
io.ReadAll(resp.Body)
resp.Body.Close()
start := time.Now()
wg.Wait()
srv.Close()
var edgeL, audit map[string]any
for _, l := range g.buf.lines(t) {
if l["msg"] == "request" {
edgeL = l
} else {
audit = l
}
}
if audit == nil || audit["msg"] != c.want || audit["request_id"] != edgeL["request_id"] || audit["trace_id"] != edgeL["trace_id"] {
t.Errorf("audit=%v edge=%v; want %s", audit, edgeL, c.want)
}
if c.want == "audit failed" && (!strings.Contains(audit["err"].(string), "deadline") || time.Since(start) > 300*time.Millisecond) {
t.Errorf("期限で諦めていない: %v(待ち %v)", audit["err"], time.Since(start))
}
}
defer func() {
if recover() == nil {
t.Error("Detach(ctx, 0) が panic しなかった")
}
}()
edge.Detach(context.Background(), 0)
}
// K11: Recover は外せない。panic は 500・ログ 1 行(user・panic・stack 付き)。user は SetUser で利用者の認証から入る。
func TestK11_PanicOneLineWithUser(t *testing.T) {
g := newRig(nil)
auth := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
edge.SetUser(r.Context(), r.Header.Get("X-User"))
next.ServeHTTP(w, r)
})
}
mux := http.NewServeMux()
mux.Handle("GET /export", auth(edge.E(func(w http.ResponseWriter, r *http.Request) error {
var m map[string]int
m["x"] = 1
return nil
})))
_, h := g.build(t, mux)
w := do(h, "GET", "/export", map[string]string{"X-User": "alice"})
lines := g.buf.lines(t)
if w.Code != 500 || len(lines) != 1 || lines[0]["user"] != "alice" || lines[0]["panic"] == nil ||
!strings.Contains(lines[0]["stack"].(string), "edge_test.go") || strings.Contains(w.Body.String(), "nil map") {
t.Fatalf("code=%d body=%q lines=%v", w.Code, w.Body.String(), lines)
}
}
// K12: New の後で呼び出し側が Probes のスライスを書き換えても、Edge は変わらない。
func TestK12_ConfigCopied(t *testing.T) {
g := newRig(nil)
probes := []string{"/livez"}
g.cfg.Probes = probes
e, err := edge.New(g.cfg)
if err != nil {
t.Fatal(err)
}
probes[0] = "/nope"
mux := okMux()
mux.Handle("GET /livez", edge.E(func(w http.ResponseWriter, r *http.Request) error { return nil }))
if _, err := e.Handler(mux); err != nil {
t.Fatalf("書き換えが Edge に届いた: %v", err)
}
}
// K13: SLI は Day 073 の D8 と同じ配分で 0.9(探針 120 件は入らない)。
func TestK13_SLI(t *testing.T) {
g := newRig(nil)
mux := http.NewServeMux()
mux.Handle("GET /healthz", edge.E(func(w http.ResponseWriter, r *http.Request) error { return nil }))
mux.Handle("GET /readyz", edge.E(func(w http.ResponseWriter, r *http.Request) error {
return &edge.StatusError{Code: 503, Msg: "not ready"}
}))
mux.Handle("GET /export", edge.E(func(w http.ResponseWriter, r *http.Request) error {
if r.Header.Get("X-Fail") != "" {
return errors.New("pq: connection refused")
}
return nil
}))
_, h := g.build(t, mux)
for i := 0; i < 100; i++ {
do(h, "GET", "/healthz", nil)
}
for i := 0; i < 20; i++ {
do(h, "GET", "/readyz", nil)
}
for i := 0; i < 9; i++ {
do(h, "GET", "/export", nil)
}
do(h, "GET", "/export", map[string]string{"X-Fail": "1"})
if rm, _ := g.metrics(t); edge.SLI(rm) != 0.9 {
t.Fatalf("SLI=%v", edge.SLI(rm))
}
}
// K14: 外の利用者が flags=01 を付けて送ると、引き継ぐ設定では比率を無視して全件が記録される(記録量の水増し)。
// 既定(信じない)なら、自分の比率に戻る。乱数の種を固定して 2,000 件。
func TestK14_ForcedSamplingNeedsTrust(t *testing.T) {
got := map[bool]int{}
for _, trust := range []bool{false, true} {
g := newRig(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)))
g.cfg.TrustUpstream = trust
_, h := g.build(t, okMux())
rng := rand.New(rand.NewPCG(1, 2))
for i := 0; i < 2000; i++ {
tp := fmt.Sprintf("00-%016x%016x-%s-01", rng.Uint64(), rng.Uint64(), upParent)
do(h, "GET", "/export", map[string]string{"traceparent": tp})
}
got[trust] = len(g.sr.Ended())
}
t.Logf("flags=01 を 2,000 件: 信じない %d 件記録・信じる %d 件記録", got[false], got[true])
if got[true] != 2000 || got[false] > 300 {
t.Fatalf("got=%v", got)
}
}
実行結果(Go 1.27.2、otel v1.45.0・otelhttp v0.70.0、2026-10-14)
$ go test -race -count=1 -v ./edge/ 2>&1 | grep -E '^(---|ok)'
--- PASS: TestK1_RequiredFieldsFailFast (0.00s)
--- PASS: TestK2_ExplicitNoopAccepted (0.00s)
--- PASS: TestK3_SamplerSelfCheck (0.00s)
--- PASS: TestK4_InternalContinuesUpstream (0.00s)
--- PASS: TestK5_DefaultDistrustsHeaders (0.00s)
--- PASS: TestK6_RouteIsMuxPattern (0.01s)
--- PASS: TestK7_ProbesByPattern (0.00s)
--- PASS: TestK8_ProbeMistakesAtStartup (0.00s)
--- PASS: TestK9_LoggerKeysTopLevel (0.00s)
--- PASS: TestK10_Detach (0.06s)
--- PASS: TestK11_PanicOneLineWithUser (0.00s)
--- PASS: TestK12_ConfigCopied (0.00s)
--- PASS: TestK13_SLI (0.00s)
--- PASS: TestK14_ForcedSamplingNeedsTrust (0.35s)
ok d74/edge 1.589s
-race -count=3 でも 14 本すべて通った。go vet ./... も指摘なし。
🪜 Step-by-Step 解説
Step 1: 選ばせる値段を、過去のバグで数える
Day 063〜073 で直したバグのうち、「利用者が Edge を組み立てる・設定する」所で起きたものを 16 個並べ、3 つの API の形で書けるかを分けました。
| # | バグ(出た日) | A 部品箱 | C 関数オプション(otelhttp 型の既定) | B 採用案 | B で防いだ証拠 |
|---|---|---|---|---|---|
| 1 | ルート名に生パス(Day 063、1,000 系列) | 書ける | 書ける(SpanNameFormatter) | 書けない | K6・変異 M7 |
| 2 | 積む順の間違い(Day 065・066) | 書ける(24 中 22) | 書けない | 書けない | 実測 1 |
| 3 | Recover の付け忘れ(Day 071 R8) | 書ける | 書けない | 書けない | K11・M14 |
| 4 | otelhttp にも MeterProvider を渡して二重計上(Day 072) | 書ける | 書ける | 書けない | 中で noop に固定(導出) |
| 5 | LogHandler の包み忘れ(Day 072) | 書ける | 書ける | 減るだけ | slog.Default() で書けば鍵は付かない |
| 6 | propagator を大域に任せる(Day 073 B1) | 書ける | 書ける(既定が大域) | 書けない | K4 |
| 7 | 親を見ない sampler(Day 073 B2) | 書ける | 書ける | 起動時に止まる | K3・M2 |
| 8 | Baggage も読む(Day 073 W1) | 書ける | 書ける | 書けない | K4・M6 |
| 9 | 要求の ctx を goroutine に渡す(Day 073 B4) | 書ける | 書ける | 減るだけ | Detach を使わなければ書ける |
| 10 | cancel を外して期限を掛けない(Day 073 W3) | 書ける | 書ける | Detach を使えば書けない | K10・M11 |
| 11 | 探針を 2 か所で別々に判定(Day 073 B5) | 書ける | 書ける | 書けない | K7・M18 |
| 12 | Provider に nil を渡して大域に落ちる(今日) | 書ける | 書ける(config.go 79 行) | 起動時に止まる | K1・M1 |
| 13 | 外の flags=01 で記録量が水増し(今日) | 書ける(既定で信じる) | 書ける(既定で信じる) | 既定で起きない | K14・M5 |
| 14 | 探針 /healthz が "/" に落ちて全ルートが探針になる(今日) | — | — | 起きない | K8・M8 |
| 15 | 設定のスライスを後から書き換える(Day 067 B2 と同じ形) | 書ける | 書ける | 書けない | K12・M10 |
| 16 | 探針のつもりでメソッド付き "GET /livez" を渡す(今日) | — | — | 起動時に止まる | K8・M19 |
「書けない」「起動時に止まる」「既定で起きない」は B で 14 個、「減るだけ」が 2 個(#5・#9)。A は 14 個すべて書ける(#14・#16 は A に探針の設定が無いので対象外)。この表の A・C の列は導出(今日 A・C を実装して流したのは #2 だけ)。
Step 2: 形は Config 構造体。ただし「ゼロ値で使える」はあえて捨てた
Go には「ゼロ値を役に立つものにする」という考え方があり、http.Server{} や slog.HandlerOptions{} はゼロ値のまま動きます。今日の Config{} は動きません(K1)。
理由は Day 073 B1 です。Provider が無いときに「大域に落ちる」も「何もしないで動く」も、テストは通り、本番だけで静かに外れる。だから Provider の 3 つは「ゼロ値=error」にし、止めたいときは noop を明示させました(K2)。
一方で、ゼロ値に意味を持たせられる項目はゼロ値=安全な側にしました。
TrustUpstream bool: ゼロ値は「信じない」Probes []string: ゼロ値は「既定の 2 つだけ」
Step 3: 最初は Public bool にして、途中で TrustUpstream に裏返した
最初の版は Public bool(true なら信じない)でした。K4・K5 は通りました。名前を付け直したのは、付け忘れたときに起きることが非対称だと気づいたからです。
| 付け忘れ | 起きること | 気づけるか |
|---|---|---|
Public 版で、公開の入口なのに付け忘れ | 外の利用者が trace_id を選べる。flags=01 を付ければ比率を無視して全件記録(K14 で 2,000 件中 2,000 件) | 請求書が来るまで気づかない |
TrustUpstream 版で、社内の入口なのに付け忘れ | 上流 BFF とのトレースが link でつながるだけ(親子にならない) | トレース画面ですぐ分かる |
壊れ方が見える方をゼロ値にしました。Day 066 の「フィールドは緩める向きの名前、ゼロ値は最も厳しい」と同じ規則です。
Step 4: sampler は「選ばせる」が「検査する」
sampler を Edge が作る(Config.Ratio float64)案もありました。しかしそうすると、利用者は自分の TracerProvider(エクスポーターやリソース属性が入っている)を Edge 用に作り直すことになります。
採ったのは、利用者の TracerProvider に親付きの span を 2 本作らせて判断を見る方法です。
- 上流が記録した親(flags=01・trace_id の下位 8 バイトが
ff…ff)→ 記録しなければ error - 上流が捨てた親(flags=00・下位 8 バイトが
00…01)→ 記録したら error
trace_id を両端に選んだので、TraceIDRatioBased(r) を裸で使うと、r が 0 でも 1 でも中間でも必ずどちらかで落ちます(K3 の 5 通り)。検査の span は End しないので、エクスポーターには出ません(K3 で SpanRecorder の Ended が 0。変異 M3 で End を足すと K3・K4・K5・K7・K14 が落ちる)。費用は New 1 回あたり約 1.1 µs・14 allocs。
Step 5: ルート名も探針も「mux が選んだパターン」1 つにする
Day 073 の B5 は「Filter はパス、Edge はルート」で判定がずれたことでした。今日はさらに一段減らし、判定の材料を mux が選んだパターン 1 つにしました。
- 最も外側の 1 段で
mux.Handler(r)を 1 回だけ引き、ルートと「探針か」を袋に書く。otelhttp の Filter・span 名・Edge は袋を読むだけ - 探針はパスで受け取り(
"/livez")、Handler(mux)のときに mux に引かせてパターンに直す。mux.Handle("/livez", …)のようにメソッドなしで登録されていても"/livez"として一致する(K7)
ここで 1 つ罠がありました。S3 のように "/" で全部受ける mux では、/healthz を引くと "/" が返ります。そのまま探針にするとサイト全体が探針扱いになり、メトリクスから全部消えます。パターンのパスが探針のパスと完全に一致するときだけ採用し、明示した探針がずれたら起動時に error、既定の探針がずれたら使わないことにしました(K8・変異 M8)。
Step 6: 利用者のミドルウェアは「mux の中」にだけ置ける
認証(Day 065 の Auth)は利用者ごとに違うので、Edge の中に入れられません。かといって Handler に「Edge と Recover の間に入れるミドルウェア」を受ける引数を作ると、積む順の選択肢が戻ってきます。
そこで、利用者のミドルウェアは mux に登録するハンドラを包む形でだけ書けるようにし、Edge に知らせたいこと(user)は SetUser(ctx, user) で袋に書かせました。mux は必ず Recover の内側なので、Day 065 の「Recover は Auth より外」は型の構造で満たされます(K11 で認証中の panic でも user 付きの 1 行)。
📏 実測(予想と外れた所を含む)
時間の値は、ロードアベレージ 19〜39 の中で測ったので比較に使わない。割り当ては負荷に左右されないので根拠に使う。
1. 部品箱にしたら、積む順は何通り正しいか(P1)
Day 073 の作業ディレクトリで、Stack の 1 行だけを 24 通りに書き換え、Day 072 の O1〜O11 と Day 073 の D1〜D8(19 本)を流した。
| 並び(外→内。最内は Adapt) | 落ちたテスト |
|---|---|
| otelhttp → Edge → Identify → Recover(Day 073) | なし |
| otelhttp → Edge → Recover → Identify | なし |
| otelhttp → Identify → Edge → Recover | O3・O7 |
| otelhttp → Recover → Edge → Identify | O1・O2・O3 |
| Edge が最も外(6 通り) | O2・O3・O5・O7・O11・D1・D2・D5・D6(9 本) |
| Recover が最も外(6 通り) | 3〜10 本 |
| Identify が最も外(6 通り) | 4〜5 本 |
24 通り中 2 通りだけが全部通る(Identify と Recover の順は自由)。22 通りはコンパイルが通り、1 本以上のテストで落ちる。B の API では利用者が書ける並びは 1 通り。
2. パッケージにした費用(P2)
| B/op | allocs/op | |
|---|---|---|
| Day 073 の Stack(ルート名は定数を返す関数) | 9,877 | 63 |
今日の Handler(mux)(mux の振り分け+mux.Handler でルート名) | 10,021〜10,023 | 63 |
増えたのは +144〜146 B・0 allocs。ルート名を引く外側の 1 段で袋を作るので、Day 073 で Edge が行っていた r.WithContext がそこへ移っただけで、回数は増えなかった。New 自体(自己点検込み)は 1,334 B・14 allocs で、起動時に 1 回だけ。
3. 上流を信じると、外の利用者が記録量を決められる(P3)
ParentBased(TraceIDRatioBased(0.1))、乱数の種を固定した trace_id 2,000 件に flags=01 を付けて送った(K14)。
| 設定 | 記録された span |
|---|---|
TrustUpstream: false(既定) | 206 件(10.3%) |
TrustUpstream: true | 2,000 件(100%) |
信じる設定では、外の利用者が flags=01 を付けるだけで比率 10% が無効になる。エクスポーターと保存の費用は要求数にそのまま比例する(導出)。社内の BFF の後ろでだけ true にする理由はここにある。
4. 変異 21 通り(API の決めごとを 1 つずつ戻す)
| 変異 | 戻した決めごと | 検出したテスト |
|---|---|---|
| M1 | Tracer が nil なら大域を使う(otelhttp と同じ) | K1 |
| M2 | sampler の自己点検をしない | K3 |
| M3 | 自己点検の span を End する | K3・K4・K5・K7・K14 |
| M4 | 信じない設定でも X-Request-Id を引き継ぐ | K5 |
| M5 | 信じない設定でも公開入口にしない | K5・K14 |
| M5b | TrustUpstream を常に true | K5・K14 |
| M6 | propagator に Baggage も足す | K4 |
| M7 | ルート名を生パスにする | K6・K7・K8 |
| M8 | 探針がどのパターンに落ちても採用する | K8 |
| M9 | 明示した探針が未登録でも黙って続ける | K8 |
| M10 | Probes をコピーしない | K12 |
| M11 | Detach が期限を掛けない | K10 |
| M12 | Detach が値を捨てる(Background) | K10 |
| M13 | Logger の速い道だけにする | K9 |
| M14 | Recover を外す | K11 |
| M15 | 探針の失敗もログに残さない | K7 |
| M16 | New が最初の誤りで返る(まとめて返さない) | K1 |
| M17 | 探針もメトリクスに入れる | K7・K13 |
| M18 | otelhttp の Filter を外す | K7 |
| M19 | 探針の書式を検査しない | K8 |
| M20 | 既定の探針も「明示」扱いにする | 生存 |
21 通り中 20 通りを検出。M20 は生存したが、既定の探針の resolve は戻り値を捨てているので、明示扱いにしても観測できる違いが無い(等価な変異)。
⚖️ 迷った他案と採らなかった理由
| 論点 | 採った案 | 迷った他案と採らなかった理由 | 前提が変わったら |
|---|---|---|---|
| API の形 | Config 構造体+New | 関数オプション(otelhttp・grpc-go と同じ): 後から選択肢を足しても互換を保ちやすい。ただし Go の構造体もフィールドを足すだけなら互換。今日の選択肢は 5 つで、go doc 1 画面で全部見える方を取った。部品箱: 24 通り中 22 通りが壊れる | 選択肢が 10 を超え、半分以上のサービスが既定のまま使うようになったら、関数オプションに移す |
| Provider が無いとき | error。止めるなら noop を明示 | 大域に落ちる(otelhttp の既定): main で設定すれば動く。だがテスト・別バイナリで静かに外れる(Day 073 B1)。noop で動く: 落ちないが何も出ない | 社内で「大域は必ず main の 1 行目で設定する」lint が入ったら、大域を既定にしてよい |
| sampler | 利用者の TracerProvider を受け、起動時に「親に従うか」だけ検査 | Edge が Ratio を受けて TracerProvider を作る: 間違えようがないが、利用者のエクスポーター設定と二重になる。検査しない: Day 073 B2 が戻る | ParentBased(…, WithRemoteParentSampled(比率)) のように「上流が記録したものをさらに間引く」正当な設定も、今日の検査は断る(K3 の最後の行)。必要なサービスが出たら、検査を「上流が捨てたものを記録しない」だけに緩める |
| 上流を信じるか | TrustUpstream(ゼロ値=信じない) | Public(ゼロ値=信じる): 最初の版。付け忘れの被害が見えない(Step 3)。ヘッダごとに別の設定(traceparent と X-Request-Id を別々に信じる): 選択肢が増えるだけで、片方だけ信じる理由が 3 サービスに無い | 前段の LB が traceparent を消す・付け直す構成になったら、TrustUpstream: true を全サービスの既定にしてよい |
| ルート名 | *http.ServeMux を受け、パターンを使う | RouteFunc func(*http.Request) string を受ける: chi・gorilla でも使える。だが生パスを返す関数を書ける(Day 063)。http.Handler を受けて r.Pattern を読む: Day 063・065 で、複製された要求では空になると実測済み | chi を使うサービスが出たら、RouteFunc を足すのでなく、chi 用の HandlerChi(r chi.Router) を別に作る(chi.Routes の Match でパターンを引く) |
| 探針の指定 | パスで受け、起動時に mux でパターンに直す | パターンで受ける("GET /livez"): mux の登録と 1 文字でもずれると黙って外れる。関数で受ける(IsProbe func(route) bool): 自由だが Day 073 B5 と同じく 2 か所判定に戻れる | 探針を別ポートに出す構成にしたら、Probes は要らなくなる |
| 利用者のミドルウェア | mux の中だけ(SetUser で袋に書く) | Config.Inner []Middleware: 認証を Edge と Recover の間に置ける。だが積む順の選択肢が戻る | Recover より外に置く必要のあるミドルウェア(例: 接続数の制限)が出たら、それは Edge の外に利用者が置く。Edge の中には増やさない |
| 応答後の仕事 | Detach(ctx, timeout) 関数 | (*Edge).Go(ctx, timeout, fn): WaitGroup も持たせれば、停止時に待てる。だが今日は停止処理(Server.Shutdown)との順序を決めていない | 次回(実装)で Go+Shutdown を足すときに、Detach を Go の中に閉じる |
| 誤りの返し方 | New は全部まとめて errors.Join | 最初の 1 つで返す: 直して起動し直すたびに次の誤りが出る(M16 で K1 が落ちる)。panic(Must): テストで扱いにくい | — |
🧭 設計思想・なぜこう書くのか
- 選択肢は「誰かが必ず間違える場所」: Day 063〜073 の 16 個のバグのうち 14 個は、利用者に選ばせていた所で起きていました。部品の側で決められることは決め、選ばせるのは「サービスごとに本当に違う」ものだけにします
- 間違いは起動時に:
NewとHandlerが error を返すのは、本番の 1 要求目で気づくより、デプロイの直後(Pod が Ready にならない)で気づく方が安いからです。Go のregexp.MustCompileが起動時の panic を選んでいるのと同じ方向です - ゼロ値は「安全な側」か「error」: Go は構造体のゼロ値が既定になります。ゼロ値に意味を持たせられないフィールド(Provider)は error に、持たせられるもの(TrustUpstream)は付け忘れても被害が見える側にしました
- 小さな公開面: 公開する名前は 12 個。
classOf・statusClientClosed・袋の型は非公開にしました。公開した名前は、後から変えられない約束になるからです
🌐 他言語との比較
| 言語・FW | 入口の観測を「選ばせない」仕組み | 積む順 | 今日の Go との違い |
|---|---|---|---|
| Go(今日) | edge.New(Config) が検査し、Handler が固定の順で積む | 部品の中に閉じる | 自前で書く。規則を守らせる力は「公開しない」だけ |
| ASP.NET Core | builder.Services.AddOpenTelemetry().WithTracing(…) と app.UseExceptionHandler() など。順序は Program.cs に利用者が書く(記憶) | 利用者が書く(公式文書に推奨順の表がある、記憶) | 部品箱に近い。順序の誤りは文書で防ぐ |
| Spring Boot | Micrometer Observation が自動設定で入り、ServerHttpObservationFilter の順位は固定(記憶) | 自動設定が決める | 「選ばせない」を自動設定で実現。外すにはプロパティ |
| Express / Node | @opentelemetry/sdk-node を最初に読み込ませ、Express は自動計装(記憶) | app.use の順に利用者が書く | 部品箱。Day 068 で実測したとおり next の扱いも利用者次第 |
(ASP.NET・Spring・Node の行は今回ソースも実行も確認していない)
🏭 実務での使いどころ
- 社内共通ライブラリの PR レビュー: 「この関数は公開する必要があるか」「この引数のゼロ値で何が起きるか」「nil を渡したら黙って大域に落ちないか」の 3 つを見る
- 観測の共通化: 各チームが otelhttp を直接使っていると、propagator・sampler・探針の扱いがチームごとにずれる。今日の
edgeのように 1 つのパッケージにまとめると、Day 073 のバグは 1 か所で直る - 公開の入口を足すとき: Webhook・OAuth のコールバックなど、インターネットから直接来る入口では
traceparentを信じない(K14 のとおり、記録量を外の利用者が決められる) - SPA と API を 1 つの mux に載せるサービス:
"/"の catch-all があると、パス前提の探針判定は全ルートを飲み込む。パターンが完全一致するかを起動時に確かめる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 部品を全部公開すれば、使う側が好きに組めて便利 | 自分は正しい順を知っているので | 24 通り中 22 通りがコンパイルを通って壊れる。知らない人が組むと壊れる |
WithTracerProvider(nil) は「トレースなし」 | nil=無し、と読める | otelhttp は nil を無視して大域に落ちる(config.go 79 行)。止めたいなら noop を明示 |
| Config のゼロ値は必ず動くべき | 「ゼロ値を役に立つものに」と習う | 動くことで間違いが隠れるなら、ゼロ値は error にしてよい |
Public も TrustUpstream も同じこと | true/false が裏返っただけに見える | 付け忘れたときの被害が違う。被害が見える側をゼロ値にする |
| 探針はパスで判定すれば十分 | /healthz は 1 つしかないと思う | "/" の catch-all や /healthz/ の末尾スラッシュで、判定と実際の振り分けがずれる。mux に引かせる |
| sampler は利用者の持ち物なので触れない | 中身が外から読めない | span を 1 本作ってみれば「親に従うか」は分かる。End しなければ外にも出ない |
🚧 扱わなかった範囲と理由
| 範囲 | 理由 |
|---|---|
| chi・gorilla などの router への対応 | *http.ServeMux に絞った。chi の Routes.Match は読んでいない |
停止時に応答後の仕事を待つ(Server.Shutdown・RegisterOnShutdown との順序) | 次回の実装に回す。Detach は期限だけを保証し、待つ仕組みは持たない |
| 外向きの HTTP クライアント(otelhttp の Transport)の propagator | 受ける側だけ。送る側が大域を使うと、今日の明示と食い違う可能性が残る |
| パッケージの版の付け方(v0 のうちに API を変える計画・非推奨の手順) | 社内 3 サービスの段階では決めない |
| A・C の形を実装して比べること | 比べたのは積む順(#2)だけ。他は「その形なら書けるか」を読んで分けた導出 |
| ASP.NET・Spring・Node の行 | 記憶。確認していない |
| 時間の比較 | ロードアベレージ 19〜39。割り当てだけを根拠にした |
Host 付きのパターン(example.com/healthz)の探針 | pathOf はメソッドだけを外す。Host 付きは今日の 3 サービスに無い |
➡️ 次のステップ
- 発展: chi を使う 4 つ目のサービスが来たとき、
RouteFuncを足さずに対応する方法(HandlerChi)を書き、K6〜K8 を同じ表で通す - 次回予告: 同テーマで「実装」——
Detachを(*Edge).Go(ctx, timeout, fn)に育て、http.Server.Shutdownの後に「応答後の仕事」を待つ・期限で打ち切る停止処理を書く(Shutdown が先か Go の待ちが先か、新しい要求が来たらどうするか)。10/25 にテーマが進む前の、このテーマの残り 1〜2 本