Stage 7 · Master
Phase 2 — Organization Service
Logging
Give every request a traceable ID, thread it through the error envelope, and fix a PII leak this course almost shipped — before writing a single line of alerting logic against these logs.
One ID Per Request, Visible in Every Log Line and Every Error Response
Phase 1's Logging lesson built a structured zerolog.Logger; this lesson does not replace it — it adds a request-scoped identifier that flows through that same logger and into the JSON error envelope middleware.ErrorHandler already produces, so a support engineer looking at one failed API response has, in that response, exactly the value to grep for in this service's logs.
It is worth naming the boundaries now, because logging shows up again twice. Phase 1 owns the logger itself: one constructor, two writers, the fields every line carries. This lesson owns correlation and redaction inside one service — an id a support engineer can grep for, and the guarantee that an email address never reaches a log sink. The Observability phase later owns the platform contract: binding that logger into context so call sites cannot forget the id, and carrying it across service boundaries. Nothing below re-decides the format or the level.
package middleware
import (
"context"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type contextKey string
const requestIDKey contextKey = "request_id"
// RequestID must run before every other middleware that logs or responds
// with an error, since both depend on being able to read this value back
// out of the request's context.
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
c.Header("X-Request-ID", id)
ctx := context.WithValue(c.Request.Context(), requestIDKey, id)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// RequestIDFromContext returns the empty string if called outside a
// request that passed through RequestID — never a panic — so this helper
// is always safe to call from a background job or a test context too.
func RequestIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
c.JSON(status, gin.H{
"error": gin.H{
"code": appErr.Code,
"message": appErr.Message,
"details": appErr.Details,
"request_id": RequestIDFromContext(c.Request.Context()),
},
})
Everything above this snippet in error.go — the c.Next() call, the c.Errors check, the apperr.As switch — is unchanged from the Error Handling lesson; only the final JSON payload gains one field.
func New(orgHandler *handler.OrganizationHandler, healthHandler *handler.HealthHandler, m *metrics.Metrics) *gin.Engine {
engine := gin.New()
engine.Use(middleware.RequestID())
engine.Use(gin.Recovery())
engine.Use(middleware.ErrorHandler())
engine.Use(m.Middleware())
// routes unchanged below this line
RequestID is registered before gin.Recovery so that even a request that panics still has a request ID attached to whatever Recovery logs about it.
The PII Leak This Course Almost Shipped
An early draft of the Service Layer lesson considered adding a log line to OrganizationService.Create for operational visibility. The first version written was logger.Info().Str("name", org.Name).Str("email", org.ContactEmail).Msg("organization created") — and that second field is the exact kind of line a compliance review would flag: a real HOA administrator's email address, written verbatim into a log stream that may be retained, shipped to a third-party aggregator, or accessible to engineers who have no business reason to see resident contact data.
| Version | Log call | Risk |
|---|---|---|
| Rejected | logger.Info().Str("name", org.Name).Str("email", org.ContactEmail).Msg("organization created") | Full email address retained in every log aggregator this service ships to |
| Shipped | logger.Info().Str("org_id", org.ID.String()).Str("slug", org.Slug).Str("email", logutil.RedactEmail(org.ContactEmail)).Msg("organization created") | Only a partially masked email, sufficient for incident correlation, never the full address |
package logutil
import "strings"
// RedactEmail keeps enough of an email address to be useful during an
// incident (the first character and the full domain) without writing the
// complete, personally identifying address into any log line.
func RedactEmail(email string) string {
at := strings.IndexByte(email, '@')
if at <= 1 {
return "***"
}
return email[:1] + "***" + email[at:]
}
type OrganizationService struct {
repo Repository
+ logger zerolog.Logger
}
-func NewOrganizationService(repo Repository) *OrganizationService {
- return &OrganizationService{repo: repo}
+func NewOrganizationService(repo Repository, logger zerolog.Logger) *OrganizationService {
+ return &OrganizationService{repo: repo, logger: logger}
}
func (s *OrganizationService) Create(ctx context.Context, req dto.CreateOrganizationRequest) (*model.Organization, error) {
// Construction, repository call, and domain-error mapping stay unchanged.
+ s.logger.Info().
+ Str("org_id", org.ID.String()).
+ Str("slug", org.Slug).
+ Str("email", logutil.RedactEmail(org.ContactEmail)).
+ Msg("organization created")
return org, nil
}Only Create is shown; every other OrganizationService method is unchanged except for receiving the logger field through the constructor, which they do not yet use. logger is stored and passed by value throughout — zerolog.Logger is a small, cheaply-copied struct, never a pointer.
repo := repository.NewOrganizationPostgres(pool)
svc := service.NewOrganizationService(repo, logger)
orgHandler := handler.NewOrganizationHandler(svc, validation.New())
logger already existed in main since the Service Bootstrap lesson — this is the first lesson where it is threaded past main into the service layer that actually has something worth logging.
Confirming the Request ID Actually Correlates Log and Response
The service's stdout log for the first request should show "email":"a***@willowcreek.test" rather than the full address — confirm this by eye in the terminal output above the curl commands.
Applied exercise
Confirm RedactEmail handles the edge cases a real HOA dataset would eventually contain
Real data is messier than the one example in this lesson. Test the redaction helper against inputs likely to appear eventually.
- Write a small table-driven test for logutil.RedactEmail covering: a normal address, a single-character local part ("a@test.test"), an address with no '@' at all (malformed input that somehow reached this function), and an empty string.
- Run the test and confirm none of these inputs cause a panic (specifically check the at <= 1 branch against "a@test.test", where at is exactly 1).
- Decide whether "a@test.test" redacting to "***" instead of a partially-visible form is acceptable for this platform, and document your reasoning either way.
Deliverable
A table-driven test file for RedactEmail plus a one-paragraph justification of the single-character edge case's current behavior.
Completion checks
- The test explicitly covers at <= 1 and confirms no panic occurs for any input, including the empty string.
- The justification explains why a fully-masked "***" is acceptable (or proposes and justifies a specific alternative) for single-character local parts.
Why is RequestID registered as the very first middleware in router.go, before even gin.Recovery?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.