package http import ( "context" "log/slog" "net/http" "time" "git.vakhrushev.me/av/transcriber/internal/clock" ) // webappRoute — чем в журнале обозначается всякий путь, отданный приложению. const webappRoute = "<приложение>" // journalNote — то, что обработчик оставляет слою журнала о своём запросе. // Указателем в контексте: значение кладёт слой, а заполняет обработчик ниже. type journalNote struct { outcome string } type journalKey struct{} // noteWebappOutcome оставляет исход раздачи слою журнала. func noteWebappOutcome(r *http.Request, outcome string) { if note, ok := r.Context().Value(journalKey{}).(*journalNote); ok { note.outcome = outcome } } // Journal пишет строку о каждом входящем запросе. // // **Путь, которым распоряжается спрашивающий, в журнал не идёт** — вместо него // маршрут из закрытого перечня и длина: по ним видно, что происходит, а // дословная запись сделала бы журнал местом, куда аноним пишет свой текст // произвольной длины. Корень приложения от этого правила не изъят: путь под ним // выбирает тот же спрашивающий. // // Журнал у сервиса **один**: второй, куда чужая библиотека клала путь целиком // вместе с адресом отправителя, ушёл вместе с ней. func Journal(mounts []Mount, logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := clock.Start() note := &journalNote{} ctx := context.WithValue(r.Context(), journalKey{}, note) recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(recorder, r.WithContext(ctx)) level := slog.LevelInfo if IsObservationAddress(mounts, r.URL.Path) { // Опрос здоровья и метрик идёт постоянно и полезного не несёт. level = slog.LevelDebug } attrs := []any{ "http.method", r.Method, "http.route", JournalRoute(r, mounts), "http.status_code", recorder.status, "duration_ms", time.Since(start).Milliseconds(), // Длина идёт на всякую строку, а не только на раздачу // приложения: обобщённое значение маршрута теперь достаётся и // путям под корнем приложения, и без длины по строке не видно, // спросили короткий адрес или мегабайт текста. "http.path_length", len(r.URL.Path), "transport", "http", } if note.outcome != "" { attrs = append(attrs, "webapp.outcome", note.outcome) } logger.Log(ctx, level, "Incoming request", attrs...) }) } } // JournalRoute готовит путь запроса к записи в журнал. // // **Дословно в журнал идёт только то, что принадлежит закрытому перечню**: // точные адреса наблюдения и образцы адресов приложения. Всё прочее — и путь // вне корней сервиса, и путь под корнем приложения, не совпавший ни с одним // образцом, — обозначается одним значением, а длина уходит отдельным полем. // // Прежнее правило судило по принадлежности пути сервису и оставляло зазор // шириной в корень приложения: `/app/<произвольный текст>` принадлежит сервису, // отвечает отказом неузнанному и при этом уезжал в журнал дословно. Множеством // значений под корнем распоряжается спрашивающий ровно так же, как и вне его, — // значит и правило одно на обе половины. // // Идентификатор записи из строки при этом не пропадает: его пишет обработчик // полем `record_id`, и пишет он тот, что прочитал, а не тот, что попросили. // // Имя файла в хранилище из журнала выводимо быть не должно, и сегодня оно туда // не попадает по построению: адрес копии записи назван идентификатором самой // записи, а имя файла на диске в путь не входит вовсе. func JournalRoute(r *http.Request, mounts []Mount) string { if exact, ok := ExactAddressOf(mounts, r.URL.Path); ok { return exact } if pattern, ok := appRoutePatternOf(r); ok { return pattern } return webappRoute } // appRouteIndex — маршрутизатор, заведённый ради одного вопроса: какому образцу // приложения отвечает этот запрос. // // Маршрутизатор, а не свой разбор пути: образец `{id}` разбирает стандартная // библиотека, и второй разбор рядом с ней разошёлся бы с настоящей // маршрутизацией молча. Образцы берутся тем же перечнем, которым вешаются // обработчики. var appRouteIndex = newAppRouteIndex() func newAppRouteIndex() *http.ServeMux { mux := http.NewServeMux() for _, pattern := range AppRoutePatterns { mux.Handle(pattern, http.NotFoundHandler()) } return mux } // knownAppRoutes — тот же перечень множеством: ответ маршрутизатора сверяется с // ним. Перенаправление на очищенный путь маршрутизатор отдаёт образцом, // собранным из самого пути, и без сверки такой ответ уехал бы в журнал // дословно — то есть ровно тем, чего правило не допускает. var knownAppRoutes = knownAppRouteSet() func knownAppRouteSet() map[string]struct{} { out := make(map[string]struct{}, len(AppRoutePatterns)) for _, pattern := range AppRoutePatterns { out[pattern] = struct{}{} } return out } // appRoutePatternOf называет образец адреса приложения. Второе значение ложно у // всего, что ни одному образцу не отвечает. func appRoutePatternOf(r *http.Request) (string, bool) { _, pattern := appRouteIndex.Handler(r) if _, ok := knownAppRoutes[pattern]; !ok { return "", false } return pattern, true } // statusRecorder запоминает код ответа: журнал пишется после обработчика, а // готовый код читать больше неоткуда. type statusRecorder struct { http.ResponseWriter status int written bool } func (w *statusRecorder) WriteHeader(status int) { if !w.written { w.status = status w.written = true } w.ResponseWriter.WriteHeader(status) } func (w *statusRecorder) Write(p []byte) (int, error) { w.written = true return w.ResponseWriter.Write(p) }