Files
clickhouse-tools/internal/chlog/query.go
T
unkin-agent 415bf0cce1
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add chlog CLI with chcat/chtail/chgrep entrypoints
Single Go binary for the ClickHouse log store (logs.raw): chlog with
cat/tail/grep subcommands, plus chcat/chtail/chgrep argv[0]-dispatched
symlink entrypoints. Every query is time-bounded and fully parameterized;
chgrep guards wide unfiltered scans. Ships nfpm RPM with completions and
woodpecker PR/tag pipelines mirroring node-lookup.
2026-08-23 16:43:05 +10:00

132 lines
3.4 KiB
Go

package chlog
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
const Table = "logs.raw"
// Filter describes one bounded query against logs.raw. Since/Until are
// mandatory: the table has no text index, so every query must be time-bounded.
type Filter struct {
Since time.Time
Until time.Time
Namespace string
Host string
Pod string
Container string
App string
Severity string
Stream string
Source string
Pattern string
Regex bool
IgnoreCase bool
Fields map[string]string
Limit uint64
}
// Selective reports whether the filter narrows the scan enough to be cheap:
// any of namespace, host, or app restricts to a small slice of the table.
func (f Filter) Selective() bool {
return f.Namespace != "" || f.Host != "" || f.App != ""
}
type Query struct {
SQL string
Params map[string]string
}
const selectColumns = "timestamp, host, source, namespace, pod, container, stream, severity, message, labels, fields"
func chTime(t time.Time) string {
return strconv.FormatInt(t.UnixMilli(), 10)
}
// Build renders a fully parameterized query. All user-supplied values travel
// as HTTP {name:Type} parameters, never interpolated into the SQL text.
func Build(f Filter) (Query, error) {
if f.Since.IsZero() || f.Until.IsZero() {
return Query{}, fmt.Errorf("query must be time-bounded: since/until missing")
}
if !f.Since.Before(f.Until) {
return Query{}, fmt.Errorf("empty time range: since %s is not before until %s",
f.Since.UTC().Format(time.RFC3339), f.Until.UTC().Format(time.RFC3339))
}
params := map[string]string{
"since_ms": chTime(f.Since),
"until_ms": chTime(f.Until),
}
where := []string{
"timestamp >= fromUnixTimestamp64Milli({since_ms:Int64})",
"timestamp < fromUnixTimestamp64Milli({until_ms:Int64})",
}
addEq := func(column, name, value string) {
if value == "" {
return
}
where = append(where, fmt.Sprintf("%s = {%s:String}", column, name))
params[name] = value
}
addEq("namespace", "ns", f.Namespace)
addEq("host", "host", f.Host)
addEq("pod", "pod", f.Pod)
addEq("container", "container", f.Container)
addEq("stream", "stream", f.Stream)
addEq("source", "source", f.Source)
addEq("labels['app']", "app", f.App)
if f.Severity != "" {
where = append(where, "lowerUTF8(severity) = {severity:String}")
params["severity"] = strings.ToLower(f.Severity)
}
if f.Pattern != "" {
switch {
case f.Regex:
pat := f.Pattern
if f.IgnoreCase {
pat = "(?i)" + pat
}
where = append(where, "match(message, {pattern:String})")
params["pattern"] = pat
case f.IgnoreCase:
where = append(where, "positionCaseInsensitive(message, {pattern:String}) > 0")
params["pattern"] = f.Pattern
default:
where = append(where, "position(message, {pattern:String}) > 0")
params["pattern"] = f.Pattern
}
}
keys := make([]string, 0, len(f.Fields))
for k := range f.Fields {
keys = append(keys, k)
}
sort.Strings(keys)
for i, k := range keys {
kn := fmt.Sprintf("fk%d", i)
vn := fmt.Sprintf("fv%d", i)
where = append(where, fmt.Sprintf("fields[{%s:String}] = {%s:String}", kn, vn))
params[kn] = k
params[vn] = f.Fields[k]
}
sql := fmt.Sprintf("SELECT %s FROM %s WHERE %s ORDER BY timestamp ASC",
selectColumns, Table, strings.Join(where, " AND "))
if f.Limit > 0 {
sql += " LIMIT {limit:UInt64}"
params["limit"] = strconv.FormatUint(f.Limit, 10)
}
return Query{SQL: sql, Params: params}, nil
}