1package logging
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "sort"
12 "time"
13)
14
15// GenerateSessionID creates a unique session identifier.
16// Format: YYYYMMDD_HHMMSS_xxxx (timestamp + 4 random hex chars)
17// Example: 20251217_205106_a7b3
18func GenerateSessionID() string {
19 now := time.Now()
20 random := make([]byte, 2)
21 _, _ = rand.Read(random)
22 return now.Format("20060102_150405") + "_" + hex.EncodeToString(random)
23}
24
25// ShortSessionID extracts the short ID (last 4 hex chars) from a full session ID.
26// Example: "20251217_205106_a7b3" -> "a7b3"
27func ShortSessionID(sessionID string) string {
28 if len(sessionID) < 4 {
29 return sessionID
30 }
31 return sessionID[len(sessionID)-4:]
32}
33
34// ParseSessionFilename extracts session info from a log filename.
35// Example: "session_20251217_205106_a7b3.log" -> "20251217_205106_a7b3", true
36func ParseSessionFilename(filename string) (sessionID string, ok bool) {
37 const prefix = "session_"
38 const suffix = ".log"
39
40 if len(filename) < len(prefix)+len(suffix) {
41 return "", false
42 }
43 if filename[:len(prefix)] != prefix {
44 return "", false
45 }
46 if filename[len(filename)-len(suffix):] != suffix {
47 return "", false
48 }
49
50 return filename[len(prefix) : len(filename)-len(suffix)], true
51}
52
53// SessionFilename generates the log filename for a session ID.
54// Example: "20251217_205106_a7b3" -> "session_20251217_205106_a7b3.log"
55func SessionFilename(sessionID string) string {
56 return "session_" + sessionID + ".log"
57}
58
59type sessionMetadataKey struct{}
60
61// SessionMetadata describes the active browser session log identity carried by context.
62type SessionMetadata struct {
63 ID string
64 LogPath string
65}
66
67// WithSessionMetadata attaches active session metadata to ctx.
68func WithSessionMetadata(ctx context.Context, id, logPath string) context.Context {
69 if ctx == nil {
70 ctx = context.Background()
71 }
72 return context.WithValue(ctx, sessionMetadataKey{}, SessionMetadata{ID: id, LogPath: logPath})
73}
74
75// SessionMetadataFromContext returns active session metadata from ctx, if present.
76func SessionMetadataFromContext(ctx context.Context) (SessionMetadata, bool) {
77 if ctx == nil {
78 return SessionMetadata{}, false
79 }
80 metadata, ok := ctx.Value(sessionMetadataKey{}).(SessionMetadata)
81 return metadata, ok
82}
83
84type sessionLogFile struct {
85 path string
86 filename string
87 sessionID string
88 modTime time.Time
89}
90
91// CleanupSessionLogFiles removes old session log files when their count exceeds maxFiles.
92// It keeps up to maxFiles of the newest requested session IDs, then the newest remaining files by modification time.
93// A maxFiles value of 0 disables count-based cleanup.
94func CleanupSessionLogFiles(logDir string, maxFiles int, keepSessionIDs ...string) (int, error) {
95 if logDir == "" || maxFiles <= 0 {
96 return 0, nil
97 }
98
99 entries, err := os.ReadDir(logDir)
100 if err != nil {
101 if os.IsNotExist(err) {
102 return 0, nil
103 }
104 return 0, fmt.Errorf("read log directory: %w", err)
105 }
106
107 requestedKeep := make(map[string]struct{}, len(keepSessionIDs))
108 for _, sessionID := range keepSessionIDs {
109 if sessionID == "" {
110 continue
111 }
112 requestedKeep[sessionID] = struct{}{}
113 }
114
115 files := make([]sessionLogFile, 0, len(entries))
116 operationErrs := make([]error, 0)
117 for _, entry := range entries {
118 if entry.IsDir() {
119 continue
120 }
121
122 sessionID, ok := ParseSessionFilename(entry.Name())
123 if !ok {
124 continue
125 }
126
127 info, infoErr := entry.Info()
128 if infoErr != nil {
129 operationErrs = append(operationErrs, fmt.Errorf("stat %s: %w", filepath.Join(logDir, entry.Name()), infoErr))
130 continue
131 }
132
133 files = append(files, sessionLogFile{
134 path: filepath.Join(logDir, entry.Name()),
135 filename: entry.Name(),
136 sessionID: sessionID,
137 modTime: info.ModTime(),
138 })
139 }
140
141 sort.Slice(files, func(i, j int) bool {
142 if files[i].modTime.Equal(files[j].modTime) {
143 return files[i].filename > files[j].filename
144 }
145 return files[i].modTime.After(files[j].modTime)
146 })
147
148 allowedKeep := boundedKeepSessionIDs(files, requestedKeep, maxFiles)
149 maxNewestFiles := maxFiles - len(allowedKeep)
150 kept := 0
151 keptNewest := 0
152 removed := 0
153 for _, file := range files {
154 if _, shouldKeep := allowedKeep[file.sessionID]; shouldKeep {
155 if kept < maxFiles {
156 kept++
157 continue
158 }
159 } else if keptNewest < maxNewestFiles && kept < maxFiles {
160 kept++
161 keptNewest++
162 continue
163 }
164
165 if err := os.Remove(file.path); err != nil {
166 operationErrs = append(operationErrs, fmt.Errorf("remove %s: %w", file.path, err))
167 continue
168 }
169 removed++
170 }
171
172 return removed, errors.Join(operationErrs...)
173}
174
175func boundedKeepSessionIDs(files []sessionLogFile, requestedKeep map[string]struct{}, maxFiles int) map[string]struct{} {
176 allowedKeep := make(map[string]struct{}, len(requestedKeep))
177 for _, file := range files {
178 if len(allowedKeep) >= maxFiles {
179 break
180 }
181 if _, shouldKeep := requestedKeep[file.sessionID]; shouldKeep {
182 allowedKeep[file.sessionID] = struct{}{}
183 }
184 }
185 return allowedKeep
186}