Skip to content

Commit 299842b

Browse files
Naorayclaude
andcommitted
feat(tracing): auto-detect git information
Closes #38 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7b4f091 commit 299842b

File tree

5 files changed

+330
-5
lines changed

5 files changed

+330
-5
lines changed

config/github-monolog.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@
146146
// Collect Inertia.js request context (auto-detects Inertia requests)
147147
'inertia' => true,
148148

149+
// Auto-detect git information (hash, branch, tag, dirty status)
150+
'git' => true,
151+
149152
/*
150153
|--------------------------------------------------------------------------
151154
| Query Collector Settings

src/Tracing/EnvironmentCollector.php

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ class EnvironmentCollector implements DataCollectorInterface
1010
{
1111
use ResolvesTracingConfig;
1212

13+
public function __construct(
14+
protected ?GitInfoDetector $gitInfoDetector = null,
15+
) {
16+
$this->gitInfoDetector ??= new GitInfoDetector;
17+
}
18+
1319
public function isEnabled(): bool
1420
{
1521
return $this->isTracingFeatureEnabled('environment');
@@ -20,15 +26,43 @@ public function isEnabled(): bool
2026
*/
2127
public function collect(): void
2228
{
23-
Context::add('environment', [
29+
$environment = [
2430
'app_env' => config('app.env'),
2531
'app_debug' => config('app.debug'),
2632
'app_version' => config('app.version'),
2733
'laravel_version' => app()->version(),
2834
'php_version' => PHP_VERSION,
2935
'php_os' => PHP_OS,
3036
'hostname' => gethostname() ?: null,
31-
'git_commit' => config('app.git_commit'),
32-
]);
37+
];
38+
39+
$environment = array_merge($environment, $this->collectGitInfo());
40+
41+
Context::add('environment', $environment);
42+
}
43+
44+
/**
45+
* Collect git information if enabled.
46+
*
47+
* @return array<string, string|bool|null>
48+
*/
49+
protected function collectGitInfo(): array
50+
{
51+
if (! $this->getTracingConfig('git', true)) {
52+
return ['git_commit' => config('app.git_commit')];
53+
}
54+
55+
$gitInfo = $this->gitInfoDetector->detect();
56+
57+
// config('app.git_commit') overrides auto-detected git_hash
58+
$configCommit = config('app.git_commit');
59+
if ($configCommit !== null) {
60+
$gitInfo['git_hash'] = $configCommit;
61+
}
62+
63+
// Keep backward compatibility: include git_commit key
64+
$gitInfo['git_commit'] = $gitInfo['git_hash'] ?? $configCommit;
65+
66+
return $gitInfo;
3367
}
3468
}

src/Tracing/GitInfoDetector.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
namespace Naoray\LaravelGithubMonolog\Tracing;
4+
5+
class GitInfoDetector
6+
{
7+
/**
8+
* Cached git information (null means not yet resolved).
9+
*
10+
* @var array<string, string|bool|null>|null
11+
*/
12+
protected static ?array $cachedGitInfo = null;
13+
14+
/**
15+
* Detect git information for the current working directory.
16+
*
17+
* Results are cached statically so git commands only run once per process.
18+
*
19+
* @return array<string, string|bool|null>
20+
*/
21+
public function detect(): array
22+
{
23+
if (static::$cachedGitInfo !== null) {
24+
return static::$cachedGitInfo;
25+
}
26+
27+
static::$cachedGitInfo = [];
28+
29+
$hash = $this->runGitCommand('git log --pretty="%h" -n1 HEAD');
30+
if ($hash !== null) {
31+
static::$cachedGitInfo['git_hash'] = $hash;
32+
}
33+
34+
$branch = $this->runGitCommand('git rev-parse --abbrev-ref HEAD');
35+
if ($branch !== null) {
36+
static::$cachedGitInfo['git_branch'] = $branch;
37+
}
38+
39+
$tag = $this->runGitCommand('git describe --tags --abbrev=0 2>/dev/null');
40+
if ($tag !== null) {
41+
static::$cachedGitInfo['git_tag'] = $tag;
42+
}
43+
44+
$porcelain = $this->runGitCommand('git status --porcelain');
45+
if ($porcelain !== null) {
46+
static::$cachedGitInfo['git_dirty'] = $porcelain !== '';
47+
}
48+
49+
return static::$cachedGitInfo;
50+
}
51+
52+
/**
53+
* Run a git command with a 1-second timeout.
54+
*
55+
* Returns the trimmed output on success, or null on failure.
56+
*/
57+
protected function runGitCommand(string $command): ?string
58+
{
59+
$descriptors = [
60+
0 => ['pipe', 'r'],
61+
1 => ['pipe', 'w'],
62+
2 => ['pipe', 'w'],
63+
];
64+
65+
$process = @proc_open($command, $descriptors, $pipes, base_path());
66+
67+
if (! is_resource($process)) {
68+
return null;
69+
}
70+
71+
fclose($pipes[0]);
72+
73+
stream_set_blocking($pipes[1], false);
74+
75+
$output = '';
76+
$startTime = microtime(true);
77+
$timeoutSeconds = 1.0;
78+
79+
while (! feof($pipes[1])) {
80+
$elapsed = microtime(true) - $startTime;
81+
if ($elapsed >= $timeoutSeconds) {
82+
fclose($pipes[1]);
83+
fclose($pipes[2]);
84+
proc_terminate($process);
85+
proc_close($process);
86+
87+
return null;
88+
}
89+
90+
$chunk = fread($pipes[1], 8192);
91+
if ($chunk !== false) {
92+
$output .= $chunk;
93+
}
94+
95+
if ($chunk === '' || $chunk === false) {
96+
usleep(10000); // 10ms
97+
}
98+
}
99+
100+
fclose($pipes[1]);
101+
fclose($pipes[2]);
102+
103+
$exitCode = proc_close($process);
104+
105+
if ($exitCode !== 0) {
106+
return null;
107+
}
108+
109+
return trim($output);
110+
}
111+
112+
/**
113+
* Reset the cached git information.
114+
*
115+
* Useful for testing or when the working directory changes.
116+
*/
117+
public static function resetCache(): void
118+
{
119+
static::$cachedGitInfo = null;
120+
}
121+
}

tests/Tracing/EnvironmentCollectorTest.php

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22

33
use Illuminate\Support\Facades\Context;
44
use Naoray\LaravelGithubMonolog\Tracing\EnvironmentCollector;
5+
use Naoray\LaravelGithubMonolog\Tracing\GitInfoDetector;
56

67
beforeEach(function () {
7-
$this->collector = new EnvironmentCollector;
8+
GitInfoDetector::resetCache();
89
});
910

1011
afterEach(function () {
1112
Context::flush();
13+
GitInfoDetector::resetCache();
1214
});
1315

1416
it('collects environment data', function () {
15-
$this->collector->collect();
17+
$collector = new EnvironmentCollector;
18+
$collector->collect();
1619

1720
$environment = Context::get('environment');
1821

@@ -22,3 +25,113 @@
2225
expect($environment['php_version'])->toBe(PHP_VERSION);
2326
expect($environment['php_os'])->toBe(PHP_OS);
2427
});
28+
29+
it('includes git data when available', function () {
30+
$detector = Mockery::mock(GitInfoDetector::class);
31+
$detector->shouldReceive('detect')->once()->andReturn([
32+
'git_hash' => 'abc1234',
33+
'git_branch' => 'main',
34+
'git_tag' => 'v1.0.0',
35+
'git_dirty' => false,
36+
]);
37+
38+
$collector = new EnvironmentCollector($detector);
39+
$collector->collect();
40+
41+
$environment = Context::get('environment');
42+
43+
expect($environment)
44+
->toHaveKey('git_hash', 'abc1234')
45+
->toHaveKey('git_branch', 'main')
46+
->toHaveKey('git_tag', 'v1.0.0')
47+
->toHaveKey('git_dirty', false)
48+
->toHaveKey('git_commit', 'abc1234');
49+
});
50+
51+
it('falls back gracefully when git is not available', function () {
52+
$detector = Mockery::mock(GitInfoDetector::class);
53+
$detector->shouldReceive('detect')->once()->andReturn([]);
54+
55+
$collector = new EnvironmentCollector($detector);
56+
$collector->collect();
57+
58+
$environment = Context::get('environment');
59+
60+
expect($environment)
61+
->toHaveKey('git_commit')
62+
->toHaveKey('app_env')
63+
->toHaveKey('php_version');
64+
65+
expect($environment)->not->toHaveKey('git_hash');
66+
expect($environment)->not->toHaveKey('git_branch');
67+
});
68+
69+
it('uses config override for git_hash when app.git_commit is set', function () {
70+
config(['app.git_commit' => 'config-hash-override']);
71+
72+
$detector = Mockery::mock(GitInfoDetector::class);
73+
$detector->shouldReceive('detect')->once()->andReturn([
74+
'git_hash' => 'auto-detected-hash',
75+
'git_branch' => 'main',
76+
]);
77+
78+
$collector = new EnvironmentCollector($detector);
79+
$collector->collect();
80+
81+
$environment = Context::get('environment');
82+
83+
expect($environment)
84+
->toHaveKey('git_hash', 'config-hash-override')
85+
->toHaveKey('git_commit', 'config-hash-override')
86+
->toHaveKey('git_branch', 'main');
87+
});
88+
89+
it('skips git detection when git tracing is disabled', function () {
90+
config(['github-monolog.tracing.git' => false]);
91+
92+
$detector = Mockery::mock(GitInfoDetector::class);
93+
$detector->shouldNotReceive('detect');
94+
95+
$collector = new EnvironmentCollector($detector);
96+
$collector->collect();
97+
98+
$environment = Context::get('environment');
99+
100+
expect($environment)->toHaveKey('git_commit');
101+
expect($environment)->not->toHaveKey('git_hash');
102+
expect($environment)->not->toHaveKey('git_branch');
103+
expect($environment)->not->toHaveKey('git_tag');
104+
expect($environment)->not->toHaveKey('git_dirty');
105+
});
106+
107+
it('sets git_commit from git_hash when config override is not set', function () {
108+
config(['app.git_commit' => null]);
109+
110+
$detector = Mockery::mock(GitInfoDetector::class);
111+
$detector->shouldReceive('detect')->once()->andReturn([
112+
'git_hash' => 'detected-abc',
113+
]);
114+
115+
$collector = new EnvironmentCollector($detector);
116+
$collector->collect();
117+
118+
$environment = Context::get('environment');
119+
120+
expect($environment)
121+
->toHaveKey('git_hash', 'detected-abc')
122+
->toHaveKey('git_commit', 'detected-abc');
123+
});
124+
125+
it('enables git tracing by default', function () {
126+
$detector = Mockery::mock(GitInfoDetector::class);
127+
$detector->shouldReceive('detect')->once()->andReturn([
128+
'git_hash' => 'abc1234',
129+
]);
130+
131+
$collector = new EnvironmentCollector($detector);
132+
$collector->collect();
133+
134+
$environment = Context::get('environment');
135+
136+
expect($environment)->toHaveKey('git_hash', 'abc1234');
137+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
use Naoray\LaravelGithubMonolog\Tracing\GitInfoDetector;
4+
5+
beforeEach(function () {
6+
GitInfoDetector::resetCache();
7+
});
8+
9+
afterEach(function () {
10+
GitInfoDetector::resetCache();
11+
});
12+
13+
it('detects git information from current repository', function () {
14+
$detector = new GitInfoDetector;
15+
$info = $detector->detect();
16+
17+
// We are in a git repo, so at minimum git_hash and git_branch should be present
18+
expect($info)->toHaveKey('git_hash');
19+
expect($info)->toHaveKey('git_branch');
20+
expect($info)->toHaveKey('git_dirty');
21+
22+
expect($info['git_hash'])->toBeString()->not->toBeEmpty();
23+
expect($info['git_branch'])->toBeString()->not->toBeEmpty();
24+
expect($info['git_dirty'])->toBeBool();
25+
});
26+
27+
it('caches results across multiple calls', function () {
28+
$detector = new GitInfoDetector;
29+
$first = $detector->detect();
30+
$second = $detector->detect();
31+
32+
expect($first)->toBe($second);
33+
});
34+
35+
it('resets cache when resetCache is called', function () {
36+
$detector = new GitInfoDetector;
37+
$first = $detector->detect();
38+
39+
GitInfoDetector::resetCache();
40+
41+
$second = $detector->detect();
42+
43+
// Results should be identical since we are in the same repo
44+
expect($first)->toBe($second);
45+
});
46+
47+
it('returns short hash format', function () {
48+
$detector = new GitInfoDetector;
49+
$info = $detector->detect();
50+
51+
// Short hash is typically 7-12 characters
52+
expect(strlen($info['git_hash']))->toBeLessThanOrEqual(12);
53+
expect(strlen($info['git_hash']))->toBeGreaterThanOrEqual(7);
54+
});

0 commit comments

Comments
 (0)