Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mise exec -- npx playwright test e2e/datetime.spec.mjs
mise exec -- npx playwright test e2e/levelless.spec.mjs
mise exec -- npx playwright test e2e/copy.spec.mjs
mise exec -- npx playwright test e2e/ui-prefs.spec.mjs
mise exec -- npx playwright test e2e/lucene-query.spec.mjs

# Manual test log generation
mise exec -- node e2e/loggen.mjs --count 200
Expand Down Expand Up @@ -72,6 +73,7 @@ e2e/datetime.spec.mjs Datetime range picker UI and API integration
e2e/levelless.spec.mjs Levelless log entries rendering and filtering
e2e/copy.spec.mjs Row copy button and field-value click-to-filter
e2e/ui-prefs.spec.mjs Persistent UI preferences (columns, widths, time preset, reset)
e2e/lucene-query.spec.mjs Lucene query features: existence (field:*), regex, FTS, +/-, wildcard
e2e/screenshot.mjs Screenshot generator with realistic data
e2e/loggen.mjs Manual test-data log generator (json/logfmt/mixed)
.github/workflows/ci-build-test.yml CI pipeline (build, vet, unit tests, E2E tests)
Expand Down
1 change: 1 addition & 0 deletions e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const FILE_PORT_OFFSETS = Object.freeze({
'datetime.spec.mjs': 0,
'field-filter-append.spec.mjs': 1,
'levelless.spec.mjs': 2,
'lucene-query.spec.mjs': 10,
'resize.spec.mjs': 3,
'search-caret.spec.mjs': 4,
'search.spec.mjs': 5,
Expand Down
163 changes: 163 additions & 0 deletions e2e/lucene-query.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* lucene-query.spec.mjs — Lucene-style query feature tests.
*
* Covers: field existence (field:*), regex (field:/regex/), FTS (bare keyword
* and quoted phrase), required/prohibited (+/-), and wildcard queries.
*/

import { test, expect } from '@playwright/test';
import { portForTestFile, startServer, stopServer, postJSON } from './helpers.mjs';

let server;
let baseURL;

/** Logs with varied fields and messages for query testing. */
const TEST_LINES = [
// logs with request_id
JSON.stringify({ level: 'INFO', msg: 'connection timeout', time: '2026-02-18T10:01:00Z', service: 'api-gateway', request_id: 'req-001' }),
JSON.stringify({ level: 'INFO', msg: 'connection refused', time: '2026-02-18T10:02:00Z', service: 'api-edge', request_id: 'req-002' }),
// logs without request_id
JSON.stringify({ level: 'WARN', msg: 'all good', time: '2026-02-18T10:03:00Z', service: 'auth-service' }),
JSON.stringify({ level: 'ERROR', msg: 'internal error', time: '2026-02-18T10:04:00Z', service: 'auth-service' }),
JSON.stringify({ level: 'ERROR', msg: 'gateway error', time: '2026-02-18T10:05:00Z', service: 'api-gateway', request_id: 'req-005' }),
JSON.stringify({ level: 'DEBUG', msg: 'debug trace', time: '2026-02-18T10:06:00Z', service: 'api-edge' }),
];

test.describe('lucene-query', () => {
test.beforeAll(async ({}, workerInfo) => {
const port = portForTestFile(workerInfo);
server = await startServer(port, { lines: TEST_LINES });
baseURL = `http://localhost:${port}`;
});

test.afterAll(async () => {
await stopServer(server);
});

test('field existence: request_id:* returns only logs with that field', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', { query: 'request_id:*', limit: 100 });
expect(result.status).toBe(200);
const logs = result.body.logs;
expect(logs.length).toBeGreaterThan(0);
// Every returned log must have request_id
for (const log of logs) {
expect(log.fields).toHaveProperty('request_id');
}
// Logs without request_id must not appear
const withoutRequestId = logs.filter((l) => !l.fields?.request_id);
expect(withoutRequestId).toHaveLength(0);
});

test('field existence: non-existent field returns no results', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', { query: 'nonexistent_field:*', limit: 100 });
expect(result.status).toBe(200);
expect(result.body.logs).toHaveLength(0);
});

test('regex: service:/^api-(gateway|edge)$/ matches only api services', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', {
query: 'service:/^api-(gateway|edge)$/',
limit: 100,
});
expect(result.status).toBe(200);
const logs = result.body.logs;
expect(logs.length).toBeGreaterThan(0);
for (const log of logs) {
expect(['api-gateway', 'api-edge']).toContain(log.fields?.service);
}
// auth-service must not appear
const authLogs = logs.filter((l) => l.fields?.service === 'auth-service');
expect(authLogs).toHaveLength(0);
});

test('FTS: bare keyword "timeout" matches message containing word', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', { query: 'timeout', limit: 100 });
expect(result.status).toBe(200);
const logs = result.body.logs;
expect(logs.length).toBeGreaterThan(0);
for (const log of logs) {
expect(log.message.toLowerCase()).toContain('timeout');
}
});

test('FTS: quoted phrase "connection refused" matches the exact phrase', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', {
query: '"connection refused"',
limit: 100,
});
expect(result.status).toBe(200);
const logs = result.body.logs;
expect(logs.length).toBeGreaterThan(0);
for (const log of logs) {
expect(log.message.toLowerCase()).toContain('connection refused');
}
// "connection timeout" must NOT appear
const nonMatchingLogs = logs.filter((l) => l.message.toLowerCase().includes('timeout') && !l.message.toLowerCase().includes('refused'));
expect(nonMatchingLogs).toHaveLength(0);
});

test('required/prohibited: +level:ERROR -service:auth returns only non-auth ERRORs', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', {
query: '+level:ERROR -service:auth',
limit: 100,
});
expect(result.status).toBe(200);
const logs = result.body.logs;
expect(logs.length).toBeGreaterThan(0);
for (const log of logs) {
expect(log.level).toBe('ERROR');
expect(log.fields?.service).not.toContain('auth');
}
});

test('wildcard: service:api* matches all api-prefixed services', async ({ page }) => {
await page.goto(baseURL);
const result = await postJSON(page, '/query', { query: 'service:api*', limit: 100 });
expect(result.status).toBe(200);
const logs = result.body.logs;
expect(logs.length).toBeGreaterThan(0);
for (const log of logs) {
expect(log.fields?.service).toMatch(/^api/);
}
// auth-service must not appear
const authLogs = logs.filter((l) => l.fields?.service === 'auth-service');
expect(authLogs).toHaveLength(0);
});

test('UI: regex and +/- queries highlighted correctly', async ({ page }) => {
await page.goto(baseURL);
const searchInput = page.locator('.search-editor-input');
await expect(searchInput).toBeVisible();

// Regex literal should get hl-regex class
await searchInput.fill('service:/^api-(gateway|edge)$/');
await page.waitForTimeout(100);
const hasRegexHighlight = await page.evaluate(
() => !!document.querySelector('.search-highlight .hl-regex'),
);
expect(hasRegexHighlight).toBeTruthy();

// +/- prefix operators should get hl-op class
await searchInput.fill('+level:ERROR -service:auth');
await page.waitForTimeout(100);
const opSpans = await page.evaluate(
() => Array.from(document.querySelectorAll('.search-highlight .hl-op')).map((e) => e.textContent),
);
expect(opSpans).toContain('+');
expect(opSpans).toContain('-');

// ? wildcard should get hl-wildcard class
await searchInput.fill('service:api-?');
await page.waitForTimeout(100);
const hasWildcardHighlight = await page.evaluate(
() => !!document.querySelector('.search-highlight .hl-wildcard'),
);
expect(hasWildcardHighlight).toBeTruthy();
});
});
135 changes: 130 additions & 5 deletions pkg/query/lucene.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ func (p *parser) parsePrimary() (Filter, error) {
return filter, nil
}

// Handle required (+) prefix — Lucene semantics: clause is required (same as default AND).
if p.peekChar('+') {
p.consume(1)
return p.parsePrimary()
}

// Handle prohibited (-) prefix — Lucene semantics: clause must NOT match.
if p.peekChar('-') {
p.consume(1)
filter, err := p.parsePrimary()
if err != nil {
return nil, err
}
return &NotFilter{Filter: filter}, nil
}

// Parse field:value or keyword
token := p.readToken()
if token == "" {
Expand All @@ -163,24 +179,88 @@ func (p *parser) parsePrimary() (Filter, error) {
return p.parseRange(field, value)
}

// Handle quoted strings
// Handle quoted strings (phrase match)
if strings.HasPrefix(value, "\"") {
value = strings.Trim(value, "\"")
return &FieldFilter{Field: field, Value: value, Exact: true}, nil
}

// Handle wildcards
if strings.Contains(value, "*") {
// Handle regex values: field:/regex/
if strings.HasPrefix(value, "/") {
regexStr := p.extractRegex(value)
re, err := regexp.Compile(regexStr)
if err != nil {
return nil, fmt.Errorf("invalid regex: %w", err)
}
return &RegexFilter{Field: field, Regex: re}, nil
}

// Strip boost suffix (^n) — accepted but ignored for filtering
value = stripBoost(value)

// Handle existence: field:*
if value == "*" {
return &ExistenceFilter{Field: field}, nil
}

// Handle wildcards (* and ?)
if strings.ContainsAny(value, "*?") {
return &WildcardFilter{Field: field, Pattern: value}, nil
}

return &FieldFilter{Field: field, Value: value, Exact: false}, nil
}

// Strip boost from bare keyword
token = stripBoost(token)
if token == "" {
return &AllFilter{}, nil
}

// Bare quoted phrase — search message and fields for the phrase
if len(token) >= 2 && token[0] == '"' && token[len(token)-1] == '"' {
return &KeywordFilter{Keyword: token[1 : len(token)-1]}, nil
}

// Keyword search (searches message and fields)
return &KeywordFilter{Keyword: token}, nil
}

// extractRegex extracts the regex string from a value that starts with "/".
// If the value already ends with "/" (complete token), the content between
// the slashes is returned. Otherwise, additional characters are consumed
// from the parser input until the closing "/" is found — this handles regex
// patterns that were cut short by "(" or ")" in the token reader.
func (p *parser) extractRegex(value string) string {
// Complete regex already captured (e.g. "/regex/")
if len(value) >= 2 && value[len(value)-1] == '/' {
return value[1 : len(value)-1]
Comment on lines +236 to +237

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue parsing regex after escaped trailing slash

extractRegex currently treats any token ending in / as a complete regex literal, but readToken can stop early at ( or ). For a valid query like path:/foo\/(bar|baz)/, the partial token seen here is "/foo\\/"; this branch returns foo\\ and regexp.Compile fails, so valid regex queries are rejected whenever an escaped slash appears before a parenthesized part.

Useful? React with 👍 / 👎.

}
// Partial — strip leading "/" and continue reading until closing "/"
regexStr := value[1:]
for p.pos < len(p.input) {
ch := p.input[p.pos]
if ch == '/' {
p.pos++ // consume closing /
break
}
regexStr += string(ch)
p.pos++
}
return regexStr
}

// stripBoost removes a trailing "^number" boosting suffix from a token.
// Boosting is accepted for query-string compatibility but ignored for filtering.
func stripBoost(s string) string {
if idx := strings.LastIndex(s, "^"); idx > 0 {
if _, err := strconv.ParseFloat(s[idx+1:], 64); err == nil {
return s[:idx]
}
}
return s
}

func (p *parser) parseRange(field, rangeStr string) (Filter, error) {
// Range format: [start TO end]
rangeStr = strings.TrimPrefix(rangeStr, "[")
Expand Down Expand Up @@ -428,7 +508,7 @@ func (f *KeywordFilter) Match(entry *storage.LogEntry) bool {
return false
}

// WildcardFilter matches field values with wildcards
// WildcardFilter matches field values with wildcards (* and ?)
type WildcardFilter struct {
Field string
Pattern string
Expand All @@ -450,13 +530,58 @@ func (f *WildcardFilter) Match(entry *storage.LogEntry) bool {
}
}

// Convert wildcard pattern to regex
// Convert wildcard pattern to regex (* → .*, ? → .)
pattern := strings.ReplaceAll(f.Pattern, "*", ".*")
pattern = strings.ReplaceAll(pattern, "?", ".")
pattern = "^" + pattern + "$"
matched, _ := regexp.MatchString("(?i)"+pattern, value)
return matched
}

// ExistenceFilter matches entries where the specified field is present.
// For built-in fields (level, message) it matches when the value is non-empty.
// For custom fields it matches when the key exists in the entry's Fields map.
type ExistenceFilter struct {
Field string
}

func (f *ExistenceFilter) Match(entry *storage.LogEntry) bool {
switch f.Field {
case "level":
return entry.Level != ""
case "message":
return entry.Message != ""
default:
_, ok := entry.Fields[f.Field]
return ok
}
}

// RegexFilter matches entries where the field value matches the given regular expression.
type RegexFilter struct {
Field string
Regex *regexp.Regexp
}

func (f *RegexFilter) Match(entry *storage.LogEntry) bool {
var value string

switch f.Field {
case "level":
value = entry.Level
case "message":
value = entry.Message
default:
if v, ok := entry.Fields[f.Field]; ok {
value = fmt.Sprintf("%v", v)
} else {
return false
}
}

return f.Regex.MatchString(value)
}

// TimestampRangeFilter filters by timestamp range
type TimestampRangeFilter struct {
Start time.Time
Expand Down
Loading