summaryrefslogtreecommitdiff
path: root/docs/handbook/hooks/logging-system.md
blob: 5bb0b79e01aa1512506b8c27d928f3df3a3adee0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# Logging System

## Table of Contents

- [Introduction](#introduction)
- [The log() Function](#the-log-function)
  - [Function Signature](#function-signature)
  - [Timestamp Generation](#timestamp-generation)
  - [Dual Output with tee](#dual-output-with-tee)
  - [Graceful Degradation](#graceful-degradation)
- [Log File Configuration](#log-file-configuration)
  - [The MIRROR_LOG Variable](#the-mirror_log-variable)
  - [Default Path](#default-path)
  - [Custom Log Paths](#custom-log-paths)
  - [Log File Permissions](#log-file-permissions)
- [Log Message Format](#log-message-format)
  - [Timestamp Format](#timestamp-format)
  - [Session Boundaries](#session-boundaries)
  - [Ref Update Entries](#ref-update-entries)
  - [Push Status Entries](#push-status-entries)
  - [Summary Block](#summary-block)
- [Complete Log Output Example](#complete-log-output-example)
- [Git Push Output Capture](#git-push-output-capture)
- [Log Rotation](#log-rotation)
  - [Using logrotate](#using-logrotate)
  - [Manual Rotation](#manual-rotation)
  - [Size-Based Rotation](#size-based-rotation)
- [Log Analysis](#log-analysis)
  - [Counting Mirror Sessions](#counting-mirror-sessions)
  - [Finding Failures](#finding-failures)
  - [Extracting Push Duration](#extracting-push-duration)
  - [Monitoring with tail](#monitoring-with-tail)
- [Fallback Behavior](#fallback-behavior)
- [Concurrency and Log Interleaving](#concurrency-and-log-interleaving)
- [Security Considerations](#security-considerations)

---

## Introduction

The Project-Tick `post-receive` hook (`hooks/post-receive`) includes a built-in logging system that records every mirror push operation. The system is implemented as a single bash function, `log()`, that writes timestamped messages to both standard output and a persistent log file.

The logging system is designed with three priorities:
1. **Reliability** — Logging never causes the hook to fail, even if the log file is unwritable
2. **Visibility** — Messages appear on the pusher's terminal and in the persistent log
3. **Simplicity** — A single function, no external logging frameworks

---

## The log() Function

### Function Signature

```bash
log() {
    local timestamp
    timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
    echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*"
}
```

The function accepts any number of string arguments via `$*`, which concatenates them with a space separator.

**Usage examples** from the hook:

```bash
log "=== Mirror push triggered ==="
log "  ref: $refname ($oldrev -> $newrev)"
log "Pushing to remote: $remote"
log "  ✓ Successfully pushed to $remote"
log "  ✗ FAILED to push to $remote"
log "--- Summary ---"
log "  Succeeded: ${SUCCEEDED_REMOTES[*]:-none}"
log "  Failed:    ${FAILED_REMOTES[*]:-none}"
log "=== Finished with errors ==="
log "=== Finished successfully ==="
```

### Timestamp Generation

```bash
local timestamp
timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
```

The `date` command is invoked with two key options:

| Option | Purpose |
|--------|---------|
| `-u` | Use UTC time regardless of the server's local timezone |
| `'+%Y-%m-%d %H:%M:%S UTC'` | ISO 8601-inspired format with explicit UTC suffix |

**Why UTC?** Server environments may span multiple time zones. UTC ensures all log entries are comparable without timezone conversion. The explicit `UTC` suffix in the format string makes it unambiguous — a reader seeing `[2026-04-05 14:30:00 UTC]` knows this is not local time.

**Why `local`?** The `local` keyword restricts `timestamp` to the function scope. Without it, `timestamp` would be a global variable, persisting after the function returns and potentially conflicting with other variables.

### Dual Output with tee

```bash
echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null
```

The `tee` command reads from stdin and writes to both stdout and the specified file:

```
echo "message" ──► tee ──► stdout (pusher's terminal)
                    │
                    └──► $MIRROR_LOG (append mode)
```

The `-a` flag means **append**. Without it, `tee` would truncate the log file on each write, losing previous entries.

### Graceful Degradation

```bash
echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*"
```

The error handling chain works in three stages:

1. **Primary path**: `echo | tee -a "$MIRROR_LOG"` — write to both stdout and log file
2. **Error suppression**: `2>/dev/null` — if `tee` can't write to the log file, suppress its error message (e.g., "Permission denied")
3. **Fallback**: `|| echo "[$timestamp] $*"` — if the entire `echo | tee` pipeline fails, write to stdout only

This means the log function **never fails silently** — even if the log file is inaccessible, the message still reaches the pusher's terminal. And it **never crashes the hook** — log file errors don't propagate despite `set -e` (because they're handled by the `||` fallback).

---

## Log File Configuration

### The MIRROR_LOG Variable

```bash
MIRROR_LOG="${MIRROR_LOG:-/var/log/git-mirror.log}"
```

The log file path is controlled by the `MIRROR_LOG` environment variable with a default fallback.

### Default Path

The default log file location is `/var/log/git-mirror.log`. This follows the Linux Filesystem Hierarchy Standard (FHS) convention of placing log files under `/var/log/`.

**Requirements for the default path**:
- The directory `/var/log/` must exist (it always does on standard Linux systems)
- The user running the git daemon must have write permission to the file
- The file will be created if it doesn't exist (assuming directory write permission)

### Custom Log Paths

Override the default by setting `MIRROR_LOG` in the hook's environment:

```bash
# In the git daemon's environment
export MIRROR_LOG=/var/log/project-tick/mirror.log

# Or per-repository via a wrapper
MIRROR_LOG=/home/git/logs/mirror.log hooks/post-receive
```

**Common custom paths**:

| Path | Use Case |
|------|----------|
| `/var/log/git-mirror.log` | Default — shared system log |
| `/var/log/project-tick/mirror.log` | Project-specific log directory |
| `/home/git/logs/mirror.log` | User-local log (no root needed) |
| `/tmp/mirror.log` | Temporary/testing |
| `/dev/null` | Disable file logging (stdout only) |

### Log File Permissions

Set up the log file with appropriate ownership:

```bash
# Create the log file with correct ownership
sudo touch /var/log/git-mirror.log
sudo chown git:git /var/log/git-mirror.log
sudo chmod 640 /var/log/git-mirror.log

# Or create a project-specific log directory
sudo mkdir -p /var/log/project-tick
sudo chown git:git /var/log/project-tick
sudo chmod 750 /var/log/project-tick
```

The `640` permission (`rw-r-----`) allows the git user to write and the git group to read, while preventing other users from accessing potentially sensitive information.

---

## Log Message Format

### Timestamp Format

Every log line follows this pattern:

```
[YYYY-MM-DD HH:MM:SS UTC] <message>
```

Example:
```
[2026-04-05 14:30:00 UTC] === Mirror push triggered ===
```

The square brackets delimit the timestamp, making it easy to parse programmatically:

```bash
# Extract just the messages (remove timestamps)
sed 's/^\[[^]]*\] //' /var/log/git-mirror.log

# Extract just the timestamps
grep -oP '^\[\K[^]]+' /var/log/git-mirror.log
```

### Session Boundaries

Each mirror operation is delimited by banner lines:

```
[2026-04-05 14:30:00 UTC] === Mirror push triggered ===
...
[2026-04-05 14:30:15 UTC] === Finished successfully ===
```

Or on failure:

```
[2026-04-05 14:30:00 UTC] === Mirror push triggered ===
...
[2026-04-05 14:30:15 UTC] === Finished with errors ===
```

The `===` delimiters serve as visual and programmatic session markers.

### Ref Update Entries

Each ref in the push is logged with indentation:

```
[2026-04-05 14:30:00 UTC]   ref: refs/heads/main (abc1234 -> def5678)
[2026-04-05 14:30:00 UTC]   ref: refs/tags/v1.0.0 (0000000 -> abc1234)
```

The format `($oldrev -> $newrev)` shows the transition. The all-zeros SHA (`0000000...`) indicates a new ref creation or deletion:

| Pattern | Meaning |
|---------|---------|
| `(000... -> abc...)` | New ref created |
| `(abc... -> def...)` | Ref updated |
| `(abc... -> 000...)` | Ref deleted |

### Push Status Entries

Each remote push produces a status line:

```
[2026-04-05 14:30:05 UTC] Pushing to remote: github
[2026-04-05 14:30:08 UTC]   ✓ Successfully pushed to github
```

Or on failure:

```
[2026-04-05 14:30:10 UTC] Pushing to remote: sourceforge
[2026-04-05 14:30:25 UTC]   ✗ FAILED to push to sourceforge
```

The Unicode symbols (✓ and ✗) provide quick visual scanning in the log.

### Summary Block

At the end of each session:

```
[2026-04-05 14:30:15 UTC] --- Summary ---
[2026-04-05 14:30:15 UTC]   Succeeded: github gitlab codeberg
[2026-04-05 14:30:15 UTC]   Failed:    sourceforge
```

Or when all succeed:

```
[2026-04-05 14:30:15 UTC] --- Summary ---
[2026-04-05 14:30:15 UTC]   Succeeded: github gitlab codeberg sourceforge
[2026-04-05 14:30:15 UTC]   Failed:    none
```

---

## Complete Log Output Example

A typical successful mirror operation produces:

```
[2026-04-05 14:30:00 UTC] === Mirror push triggered ===
[2026-04-05 14:30:00 UTC]   ref: refs/heads/main (a1b2c3d4e5f6 -> f6e5d4c3b2a1)
[2026-04-05 14:30:00 UTC]   ref: refs/tags/v2.1.0 (0000000000000000000000000000000000000000 -> a1b2c3d4e5f6)
[2026-04-05 14:30:00 UTC] Pushing to remote: github
To github.com:Project-Tick/Project-Tick.git
 + a1b2c3d..f6e5d4c main -> main (forced update)
 * [new tag]         v2.1.0 -> v2.1.0
[2026-04-05 14:30:03 UTC]   ✓ Successfully pushed to github
[2026-04-05 14:30:03 UTC] Pushing to remote: gitlab
To gitlab.com:Project-Tick/Project-Tick.git
 + a1b2c3d..f6e5d4c main -> main (forced update)
 * [new tag]         v2.1.0 -> v2.1.0
[2026-04-05 14:30:06 UTC]   ✓ Successfully pushed to gitlab
[2026-04-05 14:30:06 UTC] Pushing to remote: codeberg
To codeberg.org:Project-Tick/Project-Tick.git
 + a1b2c3d..f6e5d4c main -> main (forced update)
 * [new tag]         v2.1.0 -> v2.1.0
[2026-04-05 14:30:09 UTC]   ✓ Successfully pushed to codeberg
[2026-04-05 14:30:09 UTC] --- Summary ---
[2026-04-05 14:30:09 UTC]   Succeeded: github gitlab codeberg
[2026-04-05 14:30:09 UTC]   Failed:    none
[2026-04-05 14:30:09 UTC] === Finished successfully ===
```

Note that the raw `git push` output (the `To ...` and `+ ... (forced update)` lines) is **interleaved** with the hook's log messages. This is because `git push` output goes through `tee` to the log file alongside the hook's `log()` calls.

---

## Git Push Output Capture

Beyond the `log()` function's messages, the raw output of each `git push` is also captured:

```bash
git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null
```

The `2>&1` redirect merges git's stderr into stdout before piping to `tee`. Git sends progress messages and transfer statistics to stderr, so this redirect ensures the complete output is logged:

```
To github.com:Project-Tick/Project-Tick.git
 + a1b2c3d..f6e5d4c main -> main (forced update)
 * [new tag]         v2.1.0 -> v2.1.0
```

This raw output appears in the log file **without timestamps** because it bypasses the `log()` function. It sits between the "Pushing to remote:" and "✓ Successfully pushed" entries.

---

## Log Rotation

The hook appends to the log file indefinitely. Without rotation, the file will grow without bound. Here are strategies for managing log file size.

### Using logrotate

Create `/etc/logrotate.d/git-mirror`:

```
/var/log/git-mirror.log {
    weekly
    rotate 12
    compress
    delaycompress
    missingok
    notifempty
    create 640 git git
}
```

| Directive | Effect |
|-----------|--------|
| `weekly` | Rotate once per week |
| `rotate 12` | Keep 12 rotated files (3 months) |
| `compress` | Compress rotated files with gzip |
| `delaycompress` | Don't compress the most recent rotated file |
| `missingok` | Don't error if the log file doesn't exist |
| `notifempty` | Don't rotate if the file is empty |
| `create 640 git git` | Create new log file with these permissions |

### Manual Rotation

```bash
# Rotate manually
mv /var/log/git-mirror.log /var/log/git-mirror.log.1
touch /var/log/git-mirror.log
chown git:git /var/log/git-mirror.log
```

No signal or restart is needed — the hook appends to `$MIRROR_LOG` on each invocation, so it will create a new file if the old one was moved.

### Size-Based Rotation

Add a cron job that rotates when the file exceeds a certain size:

```bash
# /etc/cron.daily/git-mirror-log-rotate
#!/bin/sh
LOG=/var/log/git-mirror.log
MAX_SIZE=10485760  # 10 MB

if [ -f "$LOG" ]; then
    SIZE=$(stat -c %s "$LOG" 2>/dev/null || echo 0)
    if [ "$SIZE" -gt "$MAX_SIZE" ]; then
        mv "$LOG" "${LOG}.$(date +%Y%m%d)"
        gzip "${LOG}.$(date +%Y%m%d)"
    fi
fi
```

---

## Log Analysis

### Counting Mirror Sessions

```bash
grep -c "=== Mirror push triggered ===" /var/log/git-mirror.log
```

### Finding Failures

```bash
# Find all failure entries
grep "✗ FAILED" /var/log/git-mirror.log

# Find sessions that ended with errors
grep "=== Finished with errors ===" /var/log/git-mirror.log

# Count failures per remote
grep "✗ FAILED" /var/log/git-mirror.log | awk '{print $NF}' | sort | uniq -c | sort -rn
```

### Extracting Push Duration

Calculate the time between trigger and finish:

```bash
# Extract session start and end times
grep -E "(Mirror push triggered|Finished)" /var/log/git-mirror.log
```

### Monitoring with tail

For real-time monitoring during a push:

```bash
tail -f /var/log/git-mirror.log
```

---

## Fallback Behavior

The logging system handles the following failure scenarios:

| Scenario | Behavior |
|----------|----------|
| Log file doesn't exist | `tee` creates it (if directory is writable) |
| Log file is not writable | `tee` error suppressed; message goes to stdout only |
| Log directory doesn't exist | `tee` fails silently; message goes to stdout only |
| `/dev/null` as log path | All file output discarded; stdout works normally |
| `$MIRROR_LOG` is empty | `tee -a ""` fails; fallback echo to stdout |

In every case, the hook continues to function. Logging is strictly best-effort and never causes a hook failure.

---

## Concurrency and Log Interleaving

When multiple pushes trigger the hook simultaneously, multiple `post-receive` instances write to the same log file concurrently. The `-a` (append) flag on `tee` uses `O_APPEND` semantics, which means writes are atomic at the kernel level for sizes up to `PIPE_BUF` (4096 bytes on Linux).

Since individual log lines are well under 4096 bytes, **individual lines will not be corrupted**. However, lines from different sessions may interleave:

```
[2026-04-05 14:30:00 UTC] === Mirror push triggered ===       # Session A
[2026-04-05 14:30:00 UTC] === Mirror push triggered ===       # Session B
[2026-04-05 14:30:00 UTC]   ref: refs/heads/main (...)        # Session A
[2026-04-05 14:30:00 UTC]   ref: refs/heads/feature (...)     # Session B
```

To disambiguate, you could modify the `log()` function to include a PID:

```bash
echo "[$$][$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$$][$timestamp] $*"
```

This produces lines like `[12345][2026-04-05 14:30:00 UTC] message` which can be filtered by PID.

---

## Security Considerations

1. **Log file contents** — The log records ref names, remote names, and git push output. It should **not** contain credentials (tokens are in the git config, not in push output). However, treat the log as moderately sensitive.

2. **Log file permissions** — Use `640` or `600` permissions. Avoid world-readable (`644`) logs on multi-user systems.

3. **Log injection** — Ref names come from the pusher and appear in log messages (`log "  ref: $refname ..."`). While this is a cosmetic concern (log files aren't executed), extremely long or crafted ref names could produce misleading log entries. Git itself limits ref names to valid characters.

4. **Disk exhaustion** — Without log rotation, the log file grows indefinitely. A hostile actor with push access could trigger many pushes to fill the disk. Use log rotation and monitoring to mitigate.