Skip to content

Commit 4e880d0

Browse files
fixup! feat(services): add log and login/session services
1 parent 2a370ea commit 4e880d0

2 files changed

Lines changed: 94 additions & 205 deletions

File tree

lib/LogTailReader.php

Lines changed: 14 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,17 @@
1010
namespace OCA\ServerInfo;
1111

1212
use OCP\IConfig;
13+
use OCP\Log\IFileBased;
14+
use OCP\Log\ILogFactory;
1315

1416
class LogTailReader {
15-
private const READ_CHUNK = 96 * 1024;
16-
1717
public function __construct(
1818
private IConfig $config,
19+
private ILogFactory $logFactory,
1920
) {
2021
}
2122

2223
/**
23-
* Tail the Nextcloud JSON log and return the last $limit entries
24-
* with severity >= $minLevel (default: WARN = 2). Skips DEBUG/INFO.
25-
*
2624
* @return array{
2725
* entries: list<array{time: string, level: int, app: string, message: string}>,
2826
* available: bool,
@@ -35,87 +33,37 @@ public function recentErrors(int $limit = 8, int $minLevel = 2): array {
3533
return ['entries' => [], 'available' => false, 'reason' => 'log_type_not_file'];
3634
}
3735

38-
$path = $this->resolvePath();
39-
if ($path === null || !is_readable($path)) {
36+
$log = $this->logFactory->get('file');
37+
if (!($log instanceof IFileBased)) {
4038
return ['entries' => [], 'available' => false, 'reason' => 'log_not_readable'];
4139
}
4240

43-
$tail = $this->tailFile($path, self::READ_CHUNK);
44-
if ($tail === '') {
45-
return ['entries' => [], 'available' => true];
46-
}
47-
48-
$lines = explode("\n", $tail);
41+
$raw = $log->getEntries($limit * 10);
4942
$collected = [];
50-
// Iterate from newest to oldest.
51-
for ($i = count($lines) - 1; $i >= 0 && count($collected) < $limit; $i--) {
52-
$line = trim($lines[$i]);
53-
if ($line === '') {
54-
continue;
43+
foreach ($raw as $entry) {
44+
if (count($collected) >= $limit) {
45+
break;
5546
}
56-
$decoded = json_decode($line, true);
57-
if (!is_array($decoded)) {
58-
continue;
59-
}
60-
$level = isset($decoded['level']) ? (int)$decoded['level'] : 0;
47+
$level = (int)($entry['level'] ?? 0);
6148
if ($level < $minLevel) {
6249
continue;
6350
}
6451
$collected[] = [
65-
'time' => (string)($decoded['time'] ?? ''),
52+
'time' => (string)($entry['time'] ?? ''),
6653
'level' => $level,
67-
'app' => (string)($decoded['app'] ?? ''),
68-
'message' => $this->snippet((string)($decoded['message'] ?? '')),
54+
'app' => (string)($entry['app'] ?? ''),
55+
'message' => $this->snippet((string)($entry['message'] ?? '')),
6956
];
7057
}
7158

7259
return ['entries' => $collected, 'available' => true];
7360
}
7461

75-
private function resolvePath(): ?string {
76-
$dataDir = $this->config->getSystemValue('datadirectory', '');
77-
$default = $dataDir !== '' ? rtrim($dataDir, '/') . '/nextcloud.log' : '';
78-
$logFile = $this->config->getSystemValue('logfile', $default);
79-
if (!is_string($logFile) || $logFile === '') {
80-
return null;
81-
}
82-
return $logFile;
83-
}
84-
85-
private function tailFile(string $path, int $chunk): string {
86-
$size = @filesize($path);
87-
if ($size === false || $size === 0) {
88-
return '';
89-
}
90-
$handle = @fopen($path, 'rb');
91-
if ($handle === false) {
92-
return '';
93-
}
94-
try {
95-
$readFrom = max(0, $size - $chunk);
96-
fseek($handle, $readFrom);
97-
$data = fread($handle, $chunk) ?: '';
98-
// Drop the leading partial line so JSON parsing doesn't choke.
99-
if ($readFrom > 0) {
100-
$nl = strpos($data, "\n");
101-
if ($nl !== false) {
102-
$data = substr($data, $nl + 1);
103-
}
104-
}
105-
return $data;
106-
} finally {
107-
fclose($handle);
108-
}
109-
}
110-
11162
private function snippet(string $msg, int $max = 200): string {
11263
$msg = trim($msg);
113-
if (function_exists('mb_strlen') && mb_strlen($msg) > $max) {
64+
if (mb_strlen($msg) > $max) {
11465
return mb_substr($msg, 0, $max - 1) . '';
11566
}
116-
if (strlen($msg) > $max) {
117-
return substr($msg, 0, $max - 1) . '';
118-
}
11967
return $msg;
12068
}
12169
}

tests/lib/LogTailReaderTest.php

Lines changed: 80 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,34 @@
1111

1212
use OCA\ServerInfo\LogTailReader;
1313
use OCP\IConfig;
14+
use OCP\Log\IFileBased;
15+
use OCP\Log\ILogFactory;
16+
use OCP\Log\IWriter;
1417
use PHPUnit\Framework\MockObject\MockObject;
1518
use Test\TestCase;
1619

1720
class LogTailReaderTest extends TestCase {
1821
private IConfig&MockObject $config;
22+
private ILogFactory&MockObject $logFactory;
1923
private LogTailReader $instance;
20-
private string $tmpDir;
2124

2225
protected function setUp(): void {
2326
parent::setUp();
2427
$this->config = $this->createMock(IConfig::class);
25-
$this->instance = new LogTailReader($this->config);
26-
$this->tmpDir = sys_get_temp_dir();
28+
$this->logFactory = $this->createMock(ILogFactory::class);
29+
$this->instance = new LogTailReader($this->config, $this->logFactory);
2730
}
2831

29-
private function configReturns(string $logType, string $logFile = ''): void {
30-
$this->config->method('getSystemValue')
31-
->willReturnCallback(function (string $key) use ($logType, $logFile): string {
32-
return match ($key) {
33-
'log_type' => $logType,
34-
'datadirectory' => '',
35-
'logfile' => $logFile,
36-
default => '',
37-
};
38-
});
39-
}
40-
41-
private function setupFileLog(string $path): void {
42-
$this->configReturns('file', $path);
32+
/** @param list<array<string, mixed>> $entries */
33+
private function setupFileLog(array $entries = []): void {
34+
$this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('file');
35+
$log = $this->createMockForIntersectionOfInterfaces([IWriter::class, IFileBased::class]);
36+
$log->method('getEntries')->willReturn($entries);
37+
$this->logFactory->method('get')->with('file')->willReturn($log);
4338
}
4439

4540
public function testNonFileLogTypeReturnsUnavailable(): void {
46-
$this->configReturns('syslog');
41+
$this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('syslog');
4742

4843
$result = $this->instance->recentErrors();
4944

@@ -52,152 +47,98 @@ public function testNonFileLogTypeReturnsUnavailable(): void {
5247
$this->assertSame([], $result['entries']);
5348
}
5449

55-
public function testUnreadablePathReturnsUnavailable(): void {
56-
$this->configReturns('file', '/nonexistent/path/nextcloud.log');
50+
public function testLogNotFileBasedReturnsUnavailable(): void {
51+
$this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('file');
52+
$writer = $this->createMock(IWriter::class);
53+
$this->logFactory->method('get')->with('file')->willReturn($writer);
5754

5855
$result = $this->instance->recentErrors();
5956

6057
$this->assertFalse($result['available']);
6158
$this->assertSame('log_not_readable', $result['reason']);
6259
}
6360

64-
public function testEmptyLogFileReturnsAvailableWithNoEntries(): void {
65-
$path = tempnam($this->tmpDir, 'nc_log_test_');
66-
file_put_contents($path, '');
67-
$this->setupFileLog($path);
61+
public function testEmptyEntriesReturnsAvailableWithNoEntries(): void {
62+
$this->setupFileLog([]);
6863

69-
try {
70-
$result = $this->instance->recentErrors();
64+
$result = $this->instance->recentErrors();
7165

72-
$this->assertTrue($result['available']);
73-
$this->assertSame([], $result['entries']);
74-
} finally {
75-
unlink($path);
76-
}
66+
$this->assertTrue($result['available']);
67+
$this->assertSame([], $result['entries']);
68+
}
69+
70+
public function testReturnShape(): void {
71+
$this->setupFileLog([
72+
['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'something failed'],
73+
]);
74+
75+
$result = $this->instance->recentErrors();
76+
77+
$this->assertArrayHasKey('entries', $result);
78+
$this->assertArrayHasKey('available', $result);
79+
$this->assertTrue($result['available']);
80+
$this->assertCount(1, $result['entries']);
81+
$entry = $result['entries'][0];
82+
$this->assertArrayHasKey('time', $entry);
83+
$this->assertArrayHasKey('level', $entry);
84+
$this->assertArrayHasKey('app', $entry);
85+
$this->assertArrayHasKey('message', $entry);
86+
$this->assertSame(3, $entry['level']);
87+
$this->assertSame('core', $entry['app']);
7788
}
7889

7990
public function testEntriesBelowMinLevelAreFiltered(): void {
80-
$path = tempnam($this->tmpDir, 'nc_log_test_');
81-
$lines = [
82-
json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 0, 'app' => 'a', 'message' => 'debug']),
83-
json_encode(['time' => '2026-01-01T00:00:01+00:00', 'level' => 1, 'app' => 'a', 'message' => 'info']),
84-
json_encode(['time' => '2026-01-01T00:00:02+00:00', 'level' => 2, 'app' => 'a', 'message' => 'warn']),
85-
json_encode(['time' => '2026-01-01T00:00:03+00:00', 'level' => 3, 'app' => 'a', 'message' => 'error']),
86-
];
87-
file_put_contents($path, implode("\n", $lines) . "\n");
88-
$this->setupFileLog($path);
89-
90-
try {
91-
$result = $this->instance->recentErrors(limit: 10, minLevel: 2);
92-
93-
$this->assertTrue($result['available']);
94-
$this->assertCount(2, $result['entries']);
95-
foreach ($result['entries'] as $entry) {
96-
$this->assertGreaterThanOrEqual(2, $entry['level']);
97-
}
98-
} finally {
99-
unlink($path);
91+
$this->setupFileLog([
92+
['time' => '2026-01-01T00:00:03+00:00', 'level' => 3, 'app' => 'a', 'message' => 'error'],
93+
['time' => '2026-01-01T00:00:02+00:00', 'level' => 2, 'app' => 'a', 'message' => 'warn'],
94+
['time' => '2026-01-01T00:00:01+00:00', 'level' => 1, 'app' => 'a', 'message' => 'info'],
95+
['time' => '2026-01-01T00:00:00+00:00', 'level' => 0, 'app' => 'a', 'message' => 'debug'],
96+
]);
97+
98+
$result = $this->instance->recentErrors(limit: 10, minLevel: 2);
99+
100+
$this->assertTrue($result['available']);
101+
$this->assertCount(2, $result['entries']);
102+
foreach ($result['entries'] as $entry) {
103+
$this->assertGreaterThanOrEqual(2, $entry['level']);
100104
}
101105
}
102106

103107
public function testLimitIsRespected(): void {
104-
$path = tempnam($this->tmpDir, 'nc_log_test_');
105-
$lines = [];
108+
$entries = [];
106109
for ($i = 0; $i < 10; $i++) {
107-
$lines[] = json_encode(['time' => "2026-01-01T00:00:{$i}0+00:00", 'level' => 3, 'app' => 'test', 'message' => "error $i"]);
110+
$entries[] = ['time' => "2026-01-01T00:00:{$i}0+00:00", 'level' => 3, 'app' => 'test', 'message' => "error $i"];
108111
}
109-
file_put_contents($path, implode("\n", $lines) . "\n");
110-
$this->setupFileLog($path);
112+
$this->setupFileLog($entries);
111113

112-
try {
113-
$result = $this->instance->recentErrors(limit: 3);
114+
$result = $this->instance->recentErrors(limit: 3);
114115

115-
$this->assertTrue($result['available']);
116-
$this->assertCount(3, $result['entries']);
117-
} finally {
118-
unlink($path);
119-
}
120-
}
121-
122-
public function testReturnShape(): void {
123-
$path = tempnam($this->tmpDir, 'nc_log_test_');
124-
$line = json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'something failed']);
125-
file_put_contents($path, $line . "\n");
126-
$this->setupFileLog($path);
127-
128-
try {
129-
$result = $this->instance->recentErrors();
130-
131-
$this->assertArrayHasKey('entries', $result);
132-
$this->assertArrayHasKey('available', $result);
133-
$this->assertCount(1, $result['entries']);
134-
$entry = $result['entries'][0];
135-
$this->assertArrayHasKey('time', $entry);
136-
$this->assertArrayHasKey('level', $entry);
137-
$this->assertArrayHasKey('app', $entry);
138-
$this->assertArrayHasKey('message', $entry);
139-
$this->assertSame(3, $entry['level']);
140-
$this->assertSame('core', $entry['app']);
141-
} finally {
142-
unlink($path);
143-
}
116+
$this->assertTrue($result['available']);
117+
$this->assertCount(3, $result['entries']);
144118
}
145119

146120
public function testLongMessageIsTruncated(): void {
147-
$path = tempnam($this->tmpDir, 'nc_log_test_');
148-
$longMsg = str_repeat('a', 300);
149-
$line = json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => $longMsg]);
150-
file_put_contents($path, $line . "\n");
151-
$this->setupFileLog($path);
152-
153-
try {
154-
$result = $this->instance->recentErrors();
155-
156-
$this->assertCount(1, $result['entries']);
157-
// snippet() uses mb_strlen/mb_substr so measure in characters, not bytes
158-
$this->assertLessThanOrEqual(200, mb_strlen($result['entries'][0]['message']));
159-
} finally {
160-
unlink($path);
161-
}
162-
}
121+
$this->setupFileLog([
122+
['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => str_repeat('a', 300)],
123+
]);
163124

164-
public function testInvalidJsonLinesAreSkipped(): void {
165-
$path = tempnam($this->tmpDir, 'nc_log_test_');
166-
$lines = [
167-
'not valid json',
168-
json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'real error']),
169-
'{broken',
170-
];
171-
file_put_contents($path, implode("\n", $lines) . "\n");
172-
$this->setupFileLog($path);
173-
174-
try {
175-
$result = $this->instance->recentErrors();
176-
177-
$this->assertCount(1, $result['entries']);
178-
} finally {
179-
unlink($path);
180-
}
125+
$result = $this->instance->recentErrors();
126+
127+
$this->assertCount(1, $result['entries']);
128+
$this->assertLessThanOrEqual(200, mb_strlen($result['entries'][0]['message']));
181129
}
182130

183-
public function testEntriesReturnedNewestFirst(): void {
184-
$path = tempnam($this->tmpDir, 'nc_log_test_');
185-
$lines = [
186-
json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'a', 'message' => 'first']),
187-
json_encode(['time' => '2026-01-01T00:00:01+00:00', 'level' => 3, 'app' => 'a', 'message' => 'second']),
188-
json_encode(['time' => '2026-01-01T00:00:02+00:00', 'level' => 3, 'app' => 'a', 'message' => 'third']),
189-
];
190-
file_put_contents($path, implode("\n", $lines) . "\n");
191-
$this->setupFileLog($path);
192-
193-
try {
194-
$result = $this->instance->recentErrors();
195-
196-
$this->assertCount(3, $result['entries']);
197-
$this->assertSame('third', $result['entries'][0]['message']);
198-
$this->assertSame('first', $result['entries'][2]['message']);
199-
} finally {
200-
unlink($path);
201-
}
131+
public function testOrderFromGetEntriesIsPreserved(): void {
132+
$this->setupFileLog([
133+
['time' => '2026-01-01T00:00:02+00:00', 'level' => 3, 'app' => 'a', 'message' => 'third'],
134+
['time' => '2026-01-01T00:00:01+00:00', 'level' => 3, 'app' => 'a', 'message' => 'second'],
135+
['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'a', 'message' => 'first'],
136+
]);
137+
138+
$result = $this->instance->recentErrors();
139+
140+
$this->assertCount(3, $result['entries']);
141+
$this->assertSame('third', $result['entries'][0]['message']);
142+
$this->assertSame('first', $result['entries'][2]['message']);
202143
}
203144
}

0 commit comments

Comments
 (0)