Compare commits
14
Commits
main
..
80e0017140
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
80e0017140
|
||
|
|
b2e36be53f
|
||
|
|
325b51d840
|
||
|
|
f0aca019f3
|
||
|
|
dc3e9910a3
|
||
|
|
e96ede32ae
|
||
|
|
a48514a2eb
|
||
|
|
fa7f41c059
|
||
|
|
36e7f8a6a9
|
||
|
|
15f484f98b
|
||
|
|
70bf6a8060
|
||
|
|
98ace914f7
|
||
|
|
22652f9e53
|
||
|
|
dcf803bac1
|
-40
@@ -1,40 +0,0 @@
|
|||||||
# Builder pin and go.mod directive are the same patch release deliberately;
|
|
||||||
# an older builder reports the mismatch only after downloading the module graph.
|
|
||||||
ARG GO_VERSION=1.27.1
|
|
||||||
|
|
||||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS build
|
|
||||||
|
|
||||||
# Git supplies Go's VCS build information; only /out/logwisp crosses stages.
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG TARGETOS=linux
|
|
||||||
ARG TARGETARCH=amd64
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG REVISION=unknown
|
|
||||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
|
||||||
go build -trimpath \
|
|
||||||
-ldflags="-s -w -X logwisp/internal/version.Version=${VERSION} -X logwisp/internal/version.GitCommit=${REVISION}" \
|
|
||||||
-o /out/logwisp ./cmd/logwisp
|
|
||||||
|
|
||||||
FROM scratch
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG REVISION=unknown
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="logwisp" \
|
|
||||||
org.opencontainers.image.description="Log transport: sources, flow, sinks" \
|
|
||||||
org.opencontainers.image.source="https://github.com/lixenwraith/logwisp" \
|
|
||||||
org.opencontainers.image.revision="${REVISION}" \
|
|
||||||
org.opencontainers.image.version="${VERSION}" \
|
|
||||||
org.opencontainers.image.licenses="BSD-3-Clause"
|
|
||||||
|
|
||||||
COPY --from=build /out/logwisp /logwisp
|
|
||||||
|
|
||||||
# Numeric identity is required in scratch and satisfies a restricted pod spec.
|
|
||||||
USER 65532:65532
|
|
||||||
|
|
||||||
ENTRYPOINT ["/logwisp"]
|
|
||||||
@@ -33,7 +33,7 @@ install: build
|
|||||||
|
|
||||||
# Uninstall the binary
|
# Uninstall the binary
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(BINDIR)/$(BINARY_NAME)
|
rm -f $(BINDIR)/$(BINARY_PATH)
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<h1>LogWisp</h1>
|
<h1>LogWisp</h1>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.27.1-00ADD8?style=flat&logo=go" alt="Go"></a>
|
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||||
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License"></a>
|
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License"></a>
|
||||||
<a href="doc/"><img src="https://img.shields.io/badge/Docs-Available-green.svg" alt="Documentation"></a>
|
<a href="doc/"><img src="https://img.shields.io/badge/Docs-Available-green.svg" alt="Documentation"></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -137,7 +137,7 @@ synthetic generator writing JSON to stdout.
|
|||||||
|
|
||||||
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
|
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
|
||||||
- **Architecture**: amd64
|
- **Architecture**: amd64
|
||||||
- **Go**: 1.27.1+ to build from source
|
- **Go**: 1.26+ to build from source
|
||||||
|
|
||||||
Network sources and sinks bind and dial over IPv4 only.
|
Network sources and sinks bind and dial over IPv4 only.
|
||||||
|
|
||||||
|
|||||||
@@ -152,9 +152,6 @@ directory = "./" # Directory to monitor (required, not re
|
|||||||
pattern = "*.log" # Glob pattern (* and ? only)
|
pattern = "*.log" # Glob pattern (* and ? only)
|
||||||
## Tailing an already-open file polls at a fixed 100ms, regardless of this value.
|
## Tailing an already-open file polls at a fixed 100ms, regardless of this value.
|
||||||
check_interval_ms = 100 # Directory rescan interval (min 10)
|
check_interval_ms = 100 # Directory rescan interval (min 10)
|
||||||
## raw = true never parses a line; with format type "raw" the file is relayed byte for byte.
|
|
||||||
raw = false # Keep the whole line as the message
|
|
||||||
from = "end" # "end" or "start" of a newly discovered file
|
|
||||||
|
|
||||||
## Console source (stdin, single instance per pipeline)
|
## Console source (stdin, single instance per pipeline)
|
||||||
# [[pipelines.plugin_sources]]
|
# [[pipelines.plugin_sources]]
|
||||||
|
|||||||
+8
-16
@@ -30,10 +30,8 @@ Omitting `[pipelines.flow.format]` entirely selects `raw`.
|
|||||||
|
|
||||||
### raw
|
### raw
|
||||||
|
|
||||||
Passthrough. `FlagRaw` bypasses formatting and sanitization: the message reaches
|
Passthrough. `FlagRaw` bypasses both formatting and sanitization, so the
|
||||||
the sink exactly as the source produced it, with no timestamp, level or source
|
message reaches the sink exactly as the source produced it.
|
||||||
prefix added. An entry that also carries `fields` gets the fields JSON appended
|
|
||||||
verbatim after a single space — `raw` never drops data and never re-encodes it.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[pipelines.flow.format]
|
[pipelines.flow.format]
|
||||||
@@ -44,12 +42,6 @@ Fastest option, and the right one when you are relaying text that is already in
|
|||||||
its final form. Note that it also bypasses sanitization, so control characters
|
its final form. Note that it also bypasses sanitization, so control characters
|
||||||
in the source data reach your sinks intact.
|
in the source data reach your sinks intact.
|
||||||
|
|
||||||
Byte-exact transport needs a source that does not split the line: the `console`
|
|
||||||
source, or the `file` source with `raw = true`. Both put the whole line —
|
|
||||||
newline included — in the message and leave `fields` empty. The `file` source's
|
|
||||||
JSON branch splits a line into message and fields, so `raw` reassembles it as
|
|
||||||
`<msg> <fields>` rather than reproducing the original object.
|
|
||||||
|
|
||||||
### txt
|
### txt
|
||||||
|
|
||||||
Human-readable line output with a timestamp and level.
|
Human-readable line output with a timestamp and level.
|
||||||
@@ -97,7 +89,7 @@ you need to override the defaults.
|
|||||||
|
|
||||||
With `flags = 0` the formatter selects `1` for `type = "raw"` and `6`
|
With `flags = 0` the formatter selects `1` for `type = "raw"` and `6`
|
||||||
(timestamp + level) for every other type. `8` is added automatically whenever an
|
(timestamp + level) for every other type. `8` is added automatically whenever an
|
||||||
entry carries parseable `fields` and `1` is not set; `1` always wins.
|
entry carries parseable `fields`.
|
||||||
|
|
||||||
Examples: `flags = 4` for level only, no timestamp; `flags = 2` for timestamp
|
Examples: `flags = 4` for level only, no timestamp; `flags = 2` for timestamp
|
||||||
only, no level.
|
only, no level.
|
||||||
@@ -141,11 +133,11 @@ when writing downstream parsers or grep patterns.
|
|||||||
|
|
||||||
## Structured Fields
|
## Structured Fields
|
||||||
|
|
||||||
When an entry carries `Fields` (raw JSON) and `FlagRaw` is not set, the
|
When an entry carries `Fields` (raw JSON), the formatter parses it and switches
|
||||||
formatter parses it and switches to structured rendering by adding the
|
to structured rendering by adding the `StructuredJSON` flag automatically.
|
||||||
`StructuredJSON` flag automatically. Fields reach a pipeline in two ways: from
|
Fields reach a pipeline in two ways: from the `file` source when a tailed line
|
||||||
the `file` source when a tailed line parses as JSON with a `fields` key, and
|
parses as JSON with a `fields` key, and from the heartbeat generator when
|
||||||
from the heartbeat generator when `include_stats = true`.
|
`include_stats = true`.
|
||||||
|
|
||||||
## Choosing a Configuration
|
## Choosing a Configuration
|
||||||
|
|
||||||
|
|||||||
+16
-27
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
|
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
|
||||||
- **Architecture**: amd64
|
- **Architecture**: amd64
|
||||||
- **Go**: 1.27.1 or newer, to build from source
|
- **Go**: 1.26 or newer, to build from source
|
||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ The Makefile works with both GNU make and BSD make. Targets:
|
|||||||
| `make` / `make build` | Build `bin/logwisp` with version metadata |
|
| `make` / `make build` | Build `bin/logwisp` with version metadata |
|
||||||
| `make dev` | Build with the race detector enabled |
|
| `make dev` | Build with the race detector enabled |
|
||||||
| `make install` | Install the binary to `$(PREFIX)/bin` (default `/usr/local`) |
|
| `make install` | Install the binary to `$(PREFIX)/bin` (default `/usr/local`) |
|
||||||
| `make uninstall` | Remove `$(BINDIR)/logwisp` |
|
| `make uninstall` | Intended to remove the installed binary — currently broken: it expands to `$(BINDIR)/bin/logwisp` instead of `$(BINDIR)/logwisp`, so it removes nothing. Delete the binary by hand |
|
||||||
| `make clean` | Remove the built binary |
|
| `make clean` | Remove the built binary |
|
||||||
| `make version` | Print the version, commit, and build time that would be embedded |
|
| `make version` | Print the version, commit, and build time that would be embedded |
|
||||||
|
|
||||||
@@ -37,24 +37,6 @@ go build -o bin/logwisp ./cmd/logwisp
|
|||||||
`go install github.com/lixenwraith/logwisp/cmd/logwisp@latest` also works, with
|
`go install github.com/lixenwraith/logwisp/cmd/logwisp@latest` also works, with
|
||||||
the same loss of version metadata.
|
the same loss of version metadata.
|
||||||
|
|
||||||
## Container Image
|
|
||||||
|
|
||||||
The root `Dockerfile` builds the same package into `scratch` under UID 65532,
|
|
||||||
static and stripped. There is no shell and no config in the image: mount one and
|
|
||||||
name it, as the binary has no daemon mode and no built-in defaults worth running.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REV=$(git rev-parse HEAD)
|
|
||||||
docker build -t "logwisp:$(git rev-parse --short HEAD)" \
|
|
||||||
--build-arg VERSION="$(git describe --tags --always)" \
|
|
||||||
--build-arg REVISION="$REV" .
|
|
||||||
docker run --rm -v /etc/logwisp:/etc/logwisp:ro logwisp:... -c /etc/logwisp/logwisp.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
Sinks that listen (`http`, `tcp`) need their ports published; the read-only
|
|
||||||
root filesystem and dropped capabilities a restricted runtime imposes are all
|
|
||||||
compatible with it, provided a `file` sink's directory is writable by 65532.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy the annotated reference configuration and edit it:
|
Copy the annotated reference configuration and edit it:
|
||||||
@@ -191,20 +173,27 @@ mode; see [Operations](operations.md#checking-a-configuration).
|
|||||||
|
|
||||||
## Test Scripts
|
## Test Scripts
|
||||||
|
|
||||||
End-to-end scripts under `test/` run against a local build:
|
Two end-to-end scripts under `test/` build multi-node chain topologies against a
|
||||||
|
local build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make
|
make
|
||||||
./test/chain-test.sh --auto # two independent relay pipelines
|
./test/chain-test.sh --auto # two independent relay pipelines
|
||||||
./test/chain-aggregate-test.sh --auto # fan-in: both edges into one pipeline
|
./test/chain-aggregate-test.sh --auto # fan-in: both edges into one pipeline
|
||||||
./test/mtls-chain-test.sh --auto # the same fan-in under mTLS
|
|
||||||
./test/passthrough-test.sh # file source relays a wide envelope intact
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `--auto` the chain scripts run the relay in the foreground for
|
Without `--auto` they run the relay in the foreground for interactive
|
||||||
interactive inspection. They need bash 5+, coreutils, and curl, and they bind
|
inspection. They need bash 5+, coreutils, and curl, and they bind ports
|
||||||
ports 15801–15804. The pass-through test binds nothing. Generated configuration
|
15801–15804. Generated configuration and logs land in `test/run/`.
|
||||||
and logs land in `test/run/`.
|
|
||||||
|
> Two of the three `--auto` assertions currently report `FAIL` against a
|
||||||
|
> working build. They grep the sink output for `"source":"edge-tcp/` and
|
||||||
|
> `"node":"edge-http"`, but the JSON formatter emits the `node/source` label
|
||||||
|
> under the key `trace`. The transport itself is healthy — the
|
||||||
|
> `total_processed` assertion passes and the streamed entries carry
|
||||||
|
> `"trace":"edge-tcp/random_rand"` as expected. Until the assertions are
|
||||||
|
> updated, verify the streams by eye with `nc 127.0.0.1 15803` and
|
||||||
|
> `curl -sN http://127.0.0.1:15804/stream`.
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
|
|||||||
+5
-18
@@ -124,9 +124,6 @@ curl -s http://127.0.0.1:8080/status | jq .
|
|||||||
"tls": false,
|
"tls": false,
|
||||||
"active_clients": 3,
|
"active_clients": 3,
|
||||||
"buffer_size": 1000,
|
"buffer_size": 1000,
|
||||||
"client_buffer_size": 256,
|
|
||||||
"max_connections": 32,
|
|
||||||
"write_timeout_ms": 5000,
|
|
||||||
"uptime_seconds": 8130
|
"uptime_seconds": 8130
|
||||||
},
|
},
|
||||||
"endpoints": { "stream": "/stream", "status": "/status" },
|
"endpoints": { "stream": "/stream", "status": "/status" },
|
||||||
@@ -148,7 +145,7 @@ This endpoint is scoped to one sink, not to the whole process, and it is
|
|||||||
| `dropped_entries` | source | Downstream cannot keep up with the source |
|
| `dropped_entries` | source | Downstream cannot keep up with the source |
|
||||||
| `total_dropped` | flow | Rate limit or filters are discarding entries (often intended) |
|
| `total_dropped` | flow | Rate limit or filters are discarding entries (often intended) |
|
||||||
| `total_dropped_by_sink` | pipeline | A sink's input queue is full |
|
| `total_dropped_by_sink` | pipeline | A sink's input queue is full |
|
||||||
| `dropped_writes` | tcp/http sink | A client's queue overflowed: either it is too slow, or one burst exceeded `client_buffer_size` |
|
| `dropped_writes` | tcp/http sink | A specific client is too slow |
|
||||||
| `rejected_conns` / `rejected_clients` | tcp/http sink, tcp_chain source | `max_connections` is being hit |
|
| `rejected_conns` / `rejected_clients` | tcp/http sink, tcp_chain source | `max_connections` is being hit |
|
||||||
| `tls_handshake_errors` | tcp sink, tcp_chain source | Certificate or version mismatch, or scanning |
|
| `tls_handshake_errors` | tcp sink, tcp_chain source | Certificate or version mismatch, or scanning |
|
||||||
| `parse_errors` | chain source | Protocol or version skew upstream |
|
| `parse_errors` | chain source | Protocol or version skew upstream |
|
||||||
@@ -185,18 +182,9 @@ the filter stage logs several lines per entry evaluated.
|
|||||||
### Buffers
|
### Buffers
|
||||||
|
|
||||||
Raise `buffer_size` when `total_dropped_by_sink` is climbing but the sink itself
|
Raise `buffer_size` when `total_dropped_by_sink` is climbing but the sink itself
|
||||||
is healthy — that is a burst-absorption problem.
|
is healthy — that is a burst-absorption problem. Raise `client_buffer_size` when
|
||||||
|
`dropped_writes` is climbing for network sinks; that is a slow-consumer problem,
|
||||||
`dropped_writes` on a network sink has two causes that a counter alone does not
|
and a bigger buffer only buys time.
|
||||||
separate. A consumer slower than the sustained rate cannot be bought off with
|
|
||||||
buffer, and drops are the intended outcome. A burst the consumer would have
|
|
||||||
drained, arriving faster than it reads, is configuration: the sink queues a
|
|
||||||
whole burst while the client writes one frame at a time, so the part of a burst
|
|
||||||
above `client_buffer_size` is lost even to a loopback reader.
|
|
||||||
Where a `rate_limit` bounds the pipeline, its `burst` is that number — keep
|
|
||||||
`client_buffer_size` at or above it and the second cause disappears. The HTTP
|
|
||||||
status endpoint reports both queue bounds alongside the counters so an operator
|
|
||||||
can tell which one is in play.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
@@ -243,8 +231,7 @@ pipeline `total_dropped_by_sink` (sink backed up?), sink `total_processed`.
|
|||||||
|
|
||||||
- The watcher seeks to end-of-file on start; only content appended afterwards is
|
- The watcher seeks to end-of-file on start; only content appended afterwards is
|
||||||
read. Positions are in memory, so a restart re-seeks to end and anything
|
read. Positions are in memory, so a restart re-seeks to end and anything
|
||||||
written during the downtime is lost. `from = "start"` reads each file whole
|
written during the downtime is lost.
|
||||||
instead, and replays it on every restart.
|
|
||||||
- `pattern` is a filename glob with `*` and `?` only, and matching is not
|
- `pattern` is a filename glob with `*` and `?` only, and matching is not
|
||||||
recursive.
|
recursive.
|
||||||
- `check_interval_ms` governs how quickly a *new file* is noticed; tailing an
|
- `check_interval_ms` governs how quickly a *new file* is noticed; tailing an
|
||||||
|
|||||||
+7
-19
@@ -140,9 +140,7 @@ allow = ["viewer-01"]
|
|||||||
|
|
||||||
**Behaviour**
|
**Behaviour**
|
||||||
|
|
||||||
- Only `GET` is routed to either path; anything else gets `405`, `HEAD` on
|
- Only `GET` is routed to either path; anything else gets `405`.
|
||||||
`stream_path` included — a stream is a body, and a client registered to have
|
|
||||||
its body discarded never reads and never leaves.
|
|
||||||
- With an `auth` block, one middleware gates **both** endpoints: an
|
- With an `auth` block, one middleware gates **both** endpoints: an
|
||||||
unauthorized client gets `403` with no body detail, and the rejection is
|
unauthorized client gets `403` with no body detail, and the rejection is
|
||||||
logged at WARN and counted in `auth_rejected`. The authorized identity is
|
logged at WARN and counted in `auth_rejected`. The authorized identity is
|
||||||
@@ -152,29 +150,19 @@ allow = ["viewer-01"]
|
|||||||
- Payloads are framed per the SSE spec, one `data:` line per newline in the
|
- Payloads are framed per the SSE spec, one `data:` line per newline in the
|
||||||
payload, so multi-line entries stream correctly.
|
payload, so multi-line entries stream correctly.
|
||||||
- The server sets no `WriteTimeout` (that would kill long-lived streams);
|
- The server sets no `WriteTimeout` (that would kill long-lived streams);
|
||||||
per-write deadlines come from `write_timeout_ms` via `http.ResponseController`
|
per-write deadlines come from `write_timeout_ms` via `http.ResponseController`.
|
||||||
and cover the connected frame, every payload, and the idle comment.
|
|
||||||
- A quiet stream emits an SSE comment every 15 s. It refreshes the client's
|
|
||||||
session and is how a peer that stopped reading is noticed.
|
|
||||||
- A client whose send queue is full has that event dropped
|
- A client whose send queue is full has that event dropped
|
||||||
(`dropped_writes`); it is not disconnected. A `dropped_writes` that rises while
|
(`dropped_writes`); it is not disconnected.
|
||||||
no client is behind is a burst larger than `client_buffer_size`, not
|
|
||||||
backpressure: size the queue at or above whatever burst the pipeline's
|
|
||||||
`rate_limit` releases at once.
|
|
||||||
- A client is registered only once its connected frame has flushed, so the
|
|
||||||
broker never queues into a buffer whose reader has not started.
|
|
||||||
- Clients whose session has been idle-expired by the session manager are
|
- Clients whose session has been idle-expired by the session manager are
|
||||||
evicted by the broker. With the idle comment above, that reaches only a peer
|
evicted by the broker.
|
||||||
that has stopped accepting bytes on a sink configured `write_timeout_ms = 0`.
|
|
||||||
- On shutdown, connected clients receive
|
- On shutdown, connected clients receive
|
||||||
`event: disconnect / data: {"reason":"server_shutdown"}`.
|
`event: disconnect / data: {"reason":"server_shutdown"}`.
|
||||||
- HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1.
|
- HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1.
|
||||||
|
|
||||||
**Status endpoint** returns service and version identity, host, port, TLS flag,
|
**Status endpoint** returns service and version identity, host, port, TLS flag,
|
||||||
the compiled auth policy, active client count, sink and per-client buffer sizes,
|
the compiled auth policy, active client count, buffer size, uptime, endpoint
|
||||||
connection limit, write timeout, uptime, endpoint paths, and the
|
paths, and the `total_processed` / `dropped_writes` / `rejected_clients` /
|
||||||
`total_processed` / `dropped_writes` / `rejected_clients` / `auth_rejected`
|
`auth_rejected` counters.
|
||||||
counters.
|
|
||||||
|
|
||||||
> Without an `auth` block both endpoints are unauthenticated, and the stream
|
> Without an `auth` block both endpoints are unauthenticated, and the stream
|
||||||
> response carries `Access-Control-Allow-Origin: *`, so any web origin can read
|
> response carries `Access-Control-Allow-Origin: *`, so any web origin can read
|
||||||
|
|||||||
+5
-21
@@ -32,8 +32,6 @@ type = "file"
|
|||||||
directory = "/var/log/myapp"
|
directory = "/var/log/myapp"
|
||||||
pattern = "*.log"
|
pattern = "*.log"
|
||||||
check_interval_ms = 100
|
check_interval_ms = 100
|
||||||
raw = false
|
|
||||||
from = "end"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
@@ -41,8 +39,6 @@ from = "end"
|
|||||||
| `directory` | string | **required** | Directory to scan; not recursive |
|
| `directory` | string | **required** | Directory to scan; not recursive |
|
||||||
| `pattern` | string | `*` | Glob over filenames; `*` and `?` only |
|
| `pattern` | string | `*` | Glob over filenames; `*` and `?` only |
|
||||||
| `check_interval_ms` | int | `100` | Directory rescan interval; minimum `10` |
|
| `check_interval_ms` | int | `100` | Directory rescan interval; minimum `10` |
|
||||||
| `raw` | bool | `false` | Never parse a line: the whole line is the message |
|
|
||||||
| `from` | string | `end` | Where a new watcher starts: `end` or `start` of the file |
|
|
||||||
|
|
||||||
**Behaviour**
|
**Behaviour**
|
||||||
|
|
||||||
@@ -53,27 +49,15 @@ from = "end"
|
|||||||
stopped and removed on the next scan.
|
stopped and removed on the next scan.
|
||||||
- A new watcher seeks to end-of-file. Positions live in memory only, so a
|
- A new watcher seeks to end-of-file. Positions live in memory only, so a
|
||||||
restart resumes from the current end of each file and content written while
|
restart resumes from the current end of each file and content written while
|
||||||
LogWisp was down is not read. `from = "start"` reads each file whole when its
|
LogWisp was down is not read.
|
||||||
watcher is created instead — what a process writing beside LogWisp needs, at
|
|
||||||
the cost of replaying a file already on disk at every restart.
|
|
||||||
- Rotation is detected from size decrease, modification-time reset, a position
|
- Rotation is detected from size decrease, modification-time reset, a position
|
||||||
beyond end-of-file, or an inode change. An inode change where the new file is
|
beyond end-of-file, or an inode change. An inode change where the new file is
|
||||||
already larger than the recorded position is treated as an atomic save, not a
|
already larger than the recorded position is treated as an atomic save, not a
|
||||||
rotation, and the position is preserved.
|
rotation, and the position is preserved.
|
||||||
- A rotation that renames in place — what a size-capped writer does — puts the
|
- Lines are parsed as JSON when they contain `time`, `level`, `msg`, and
|
||||||
same inode back under a name `pattern` also matches. Its watcher resumes at
|
`fields` keys; `time` is read as RFC3339Nano. Anything else is kept as plain
|
||||||
the position the original reached, so `from = "start"` reads the tail an
|
text with the level inferred from common markers (`[ERROR]`, `WARN:`, and so
|
||||||
unfinished read left behind rather than the whole archive a second time.
|
on).
|
||||||
- A line is parsed as JSON only when it is an object whose top-level keys are
|
|
||||||
all drawn from `time`, `level`, `msg` and `fields` — the four an entry can
|
|
||||||
carry. `time` is read as RFC3339Nano. Any other key, and any non-object line,
|
|
||||||
is kept whole as text with the level inferred from common markers
|
|
||||||
(`[ERROR]`, `WARN:`, and so on), because parsing it would drop the rest.
|
|
||||||
- `raw = true` skips the JSON branch entirely. The line, plus its newline,
|
|
||||||
becomes the message; `fields` stays empty, the time is the read time, and the
|
|
||||||
level is inferred from the text as for any unparsed line. Paired with
|
|
||||||
`format.type = "raw"` this is byte-exact transport for records LogWisp's
|
|
||||||
envelope cannot hold — see [Formatters](formatters.md#raw).
|
|
||||||
- `Source` is set to the file's base name.
|
- `Source` is set to the file's base name.
|
||||||
|
|
||||||
**Statistics**: per-watcher size, position, entries read, rotation count, and
|
**Statistics**: per-watcher size, position, entries read, rotation count, and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module logwisp
|
module logwisp
|
||||||
|
|
||||||
go 1.27.1
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98
|
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98
|
||||||
|
|||||||
@@ -198,8 +198,6 @@ type FileSourceOptions struct {
|
|||||||
Directory string `toml:"directory"`
|
Directory string `toml:"directory"`
|
||||||
Pattern string `toml:"pattern"` // glob pattern
|
Pattern string `toml:"pattern"` // glob pattern
|
||||||
CheckIntervalMS int64 `toml:"check_interval_ms"`
|
CheckIntervalMS int64 `toml:"check_interval_ms"`
|
||||||
Raw bool `toml:"raw"` // keep the whole line as the message, never parse it
|
|
||||||
From string `toml:"from"` // "end" (default) or "start" of a newly discovered file
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleSourceOptions defines settings for a stdin-based source
|
// ConsoleSourceOptions defines settings for a stdin-based source
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ const (
|
|||||||
|
|
||||||
SessionCleanupInterval = 5 * time.Minute
|
SessionCleanupInterval = 5 * time.Minute
|
||||||
|
|
||||||
// Idle keepalive for a served stream. Well under SessionDefaultMaxIdleTime,
|
|
||||||
// so a quiet stream refreshes its session long before the sweep expires it.
|
|
||||||
StreamKeepaliveInterval = 15 * time.Second
|
|
||||||
|
|
||||||
ServiceStatsUpdateInterval = 1 * time.Second
|
ServiceStatsUpdateInterval = 1 * time.Second
|
||||||
|
|
||||||
ShutdownTimeout = 10 * time.Second
|
ShutdownTimeout = 10 * time.Second
|
||||||
|
|||||||
+43
-30
@@ -90,44 +90,57 @@ func NewFormatterAdapter(cfg *config.FormatConfig) (*FormatterAdapter, error) {
|
|||||||
|
|
||||||
// Format implements Formatter interface
|
// Format implements Formatter interface
|
||||||
func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) {
|
func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) {
|
||||||
return a.serialize(entry, a.flags), nil
|
// Map logwisp LogEntry to formatter args
|
||||||
|
level := mapLevel(entry.Level)
|
||||||
|
// syslog-style origin prefix for chained entries
|
||||||
|
src := sourceLabel(entry)
|
||||||
|
|
||||||
|
// Build args based on whether we have structured fields
|
||||||
|
var args []any
|
||||||
|
effectiveFlags := a.flags
|
||||||
|
|
||||||
|
if len(entry.Fields) > 0 {
|
||||||
|
// Parse fields JSON
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(entry.Fields, &fields); err == nil && len(fields) > 0 {
|
||||||
|
// Use structured JSON format for fields
|
||||||
|
args = []any{entry.Message, fields}
|
||||||
|
// Add structured flag to properly format fields as JSON object
|
||||||
|
effectiveFlags |= formatter.FlagStructuredJSON
|
||||||
|
return a.formatter.Format(effectiveFlags, entry.Time, level, src, args), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = []any{entry.Message}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
out := bytes.Clone(a.formatter.Format(effectiveFlags, entry.Time, level, src, args))
|
||||||
|
a.mu.Unlock()
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatWithFlags allows custom flags for specific formatting needs
|
// FormatWithFlags allows custom flags for specific formatting needs
|
||||||
func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int64) ([]byte, error) {
|
func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int64) ([]byte, error) {
|
||||||
return a.serialize(entry, customFlags), nil
|
level := mapLevel(entry.Level)
|
||||||
}
|
src := sourceLabel(entry)
|
||||||
|
|
||||||
// serialize renders an entry under the given flags. The returned slice is a
|
var args []any
|
||||||
// copy: the underlying formatter reuses one buffer and sinks retain payloads.
|
if len(entry.Fields) > 0 {
|
||||||
func (a *FormatterAdapter) serialize(entry core.LogEntry, flags int64) []byte {
|
var fields map[string]any
|
||||||
args, flags := formatArgs(entry, flags)
|
if err := json.Unmarshal(entry.Fields, &fields); err == nil && len(fields) > 0 {
|
||||||
|
args = []any{entry.Message, fields}
|
||||||
|
customFlags |= formatter.FlagStructuredJSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = []any{entry.Message}
|
||||||
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
out := bytes.Clone(a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args))
|
out := bytes.Clone(a.formatter.Format(customFlags, entry.Time, level, src, args))
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return out
|
return out, nil
|
||||||
}
|
|
||||||
|
|
||||||
// formatArgs pairs the entry with its flags. FlagRaw keeps the fields JSON
|
|
||||||
// verbatim beside the message rather than silently overriding the caller's
|
|
||||||
// choice of passthrough; every other mode renders it as a JSON object.
|
|
||||||
func formatArgs(entry core.LogEntry, flags int64) ([]any, int64) {
|
|
||||||
if len(entry.Fields) == 0 {
|
|
||||||
return []any{entry.Message}, flags
|
|
||||||
}
|
|
||||||
if flags&formatter.FlagRaw != 0 {
|
|
||||||
if entry.Message == "" {
|
|
||||||
return []any{[]byte(entry.Fields)}, flags
|
|
||||||
}
|
|
||||||
return []any{entry.Message, []byte(entry.Fields)}, flags
|
|
||||||
}
|
|
||||||
|
|
||||||
var fields map[string]any
|
|
||||||
if err := json.Unmarshal(entry.Fields, &fields); err != nil || len(fields) == 0 {
|
|
||||||
return []any{entry.Message}, flags
|
|
||||||
}
|
|
||||||
return []any{entry.Message, fields}, flags | formatter.FlagStructuredJSON
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns formatter type
|
// Name returns formatter type
|
||||||
|
|||||||
+25
-67
@@ -68,7 +68,6 @@ type HTTPSink struct {
|
|||||||
clientsMu sync.Mutex
|
clientsMu sync.Mutex
|
||||||
nextClientID atomic.Uint64
|
nextClientID atomic.Uint64
|
||||||
writeTimeout time.Duration
|
writeTimeout time.Duration
|
||||||
keepalive time.Duration
|
|
||||||
|
|
||||||
// TLS
|
// TLS
|
||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
@@ -151,7 +150,6 @@ func NewHTTPSinkPlugin(
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
clients: make(map[uint64]*sseClient),
|
clients: make(map[uint64]*sseClient),
|
||||||
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
|
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
|
||||||
keepalive: core.StreamKeepaliveInterval,
|
|
||||||
tlsConfig: tlsCfg,
|
tlsConfig: tlsCfg,
|
||||||
auth: authPolicy,
|
auth: authPolicy,
|
||||||
}
|
}
|
||||||
@@ -207,10 +205,6 @@ func (h *HTTPSink) Start(ctx context.Context) error {
|
|||||||
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
|
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
|
||||||
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
|
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
|
||||||
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
|
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
|
||||||
// A GET pattern also serves HEAD, and a HEAD stream is a registered client
|
|
||||||
// whose body writes are discarded: it never reads, so nothing but the peer
|
|
||||||
// closing the connection ends it. The status path answers one either way.
|
|
||||||
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
|
|
||||||
|
|
||||||
// One wrapper covers stream and status, and keeps the handlers themselves
|
// One wrapper covers stream and status, and keeps the handlers themselves
|
||||||
// unaware of authorization
|
// unaware of authorization
|
||||||
@@ -293,9 +287,9 @@ func (h *HTTPSink) shutdown() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeClient unregisters a client; the first caller closes the send channel.
|
// removeClient unregisters a client; the first caller closes the send
|
||||||
// Broker (stale-session eviction) and stream handler (disconnect) may race here
|
// channel and removes the session. Broker (stale-session eviction) and
|
||||||
// safely. The session is the handler's, released when it returns.
|
// stream handler (disconnect) may race here safely.
|
||||||
func (h *HTTPSink) removeClient(id uint64) {
|
func (h *HTTPSink) removeClient(id uint64) {
|
||||||
h.clientsMu.Lock()
|
h.clientsMu.Lock()
|
||||||
c, ok := h.clients[id]
|
c, ok := h.clients[id]
|
||||||
@@ -305,6 +299,7 @@ func (h *HTTPSink) removeClient(id uint64) {
|
|||||||
h.clientsMu.Unlock()
|
h.clientsMu.Unlock()
|
||||||
if ok {
|
if ok {
|
||||||
close(c.send)
|
close(c.send)
|
||||||
|
h.proxy.RemoveSession(c.sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +374,10 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id := h.nextClientID.Add(1)
|
id := h.nextClientID.Add(1)
|
||||||
|
|
||||||
|
h.clientsMu.Lock()
|
||||||
|
h.clients[id] = c
|
||||||
|
h.clientsMu.Unlock()
|
||||||
|
|
||||||
count := h.activeClients.Add(1)
|
count := h.activeClients.Add(1)
|
||||||
h.logger.Debug("msg", "HTTP client connected",
|
h.logger.Debug("msg", "HTTP client connected",
|
||||||
"component", "http_sink",
|
"component", "http_sink",
|
||||||
@@ -390,7 +389,6 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
h.removeClient(id)
|
h.removeClient(id)
|
||||||
h.proxy.RemoveSession(sess.ID)
|
|
||||||
newCount := h.activeClients.Add(-1)
|
newCount := h.activeClients.Add(-1)
|
||||||
h.logger.Debug("msg", "HTTP client disconnected",
|
h.logger.Debug("msg", "HTTP client disconnected",
|
||||||
"component", "http_sink",
|
"component", "http_sink",
|
||||||
@@ -415,24 +413,11 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
"status_path": h.config.StatusPath,
|
"status_path": h.config.StatusPath,
|
||||||
"buffer_size": h.config.ClientBufferSize,
|
"buffer_size": h.config.ClientBufferSize,
|
||||||
})
|
})
|
||||||
h.armWrite(rc)
|
|
||||||
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", info)
|
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", info)
|
||||||
if err := rc.Flush(); err != nil {
|
if err := rc.Flush(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registered only now: a client the broker can queue into before its reader
|
|
||||||
// reaches the loop below loses a burst to a buffer nobody is draining.
|
|
||||||
h.clientsMu.Lock()
|
|
||||||
h.clients[id] = c
|
|
||||||
h.clientsMu.Unlock()
|
|
||||||
|
|
||||||
// A stream with nothing to carry still has to prove the peer is there. The
|
|
||||||
// comment refreshes the session the broker evicts on, and fails on a peer
|
|
||||||
// that stopped reading.
|
|
||||||
idle := time.NewTicker(h.keepalive)
|
|
||||||
defer idle.Stop()
|
|
||||||
|
|
||||||
clientGone := r.Context().Done()
|
clientGone := r.Context().Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -440,7 +425,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return // broker evicted (stale session)
|
return // broker evicted (stale session)
|
||||||
}
|
}
|
||||||
h.armWrite(rc)
|
if h.writeTimeout > 0 {
|
||||||
|
_ = rc.SetWriteDeadline(time.Now().Add(h.writeTimeout))
|
||||||
|
}
|
||||||
if err := writeSSE(w, payload); err != nil {
|
if err := writeSSE(w, payload); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -448,15 +435,6 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.proxy.UpdateActivity(sess.ID)
|
h.proxy.UpdateActivity(sess.ID)
|
||||||
case <-idle.C:
|
|
||||||
h.armWrite(rc)
|
|
||||||
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := rc.Flush(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.proxy.UpdateActivity(sess.ID)
|
|
||||||
case <-clientGone:
|
case <-clientGone:
|
||||||
return
|
return
|
||||||
case <-h.done:
|
case <-h.done:
|
||||||
@@ -467,14 +445,6 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// armWrite bounds the next response write. Without it an SSE write is unbounded
|
|
||||||
// and a peer that stops reading wedges its handler for as long as it stays open.
|
|
||||||
func (h *HTTPSink) armWrite(rc *http.ResponseController) {
|
|
||||||
if h.writeTimeout > 0 {
|
|
||||||
_ = rc.SetWriteDeadline(time.Now().Add(h.writeTimeout))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatus provides a JSON status report
|
// handleStatus provides a JSON status report
|
||||||
func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
status := map[string]any{
|
status := map[string]any{
|
||||||
@@ -482,17 +452,14 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"version": version.Short(),
|
"version": version.Short(),
|
||||||
"instance_id": h.id,
|
"instance_id": h.id,
|
||||||
"server": map[string]any{
|
"server": map[string]any{
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"host": h.config.Host,
|
"host": h.config.Host,
|
||||||
"port": h.config.Port,
|
"port": h.config.Port,
|
||||||
"tls": h.tlsConfig != nil,
|
"tls": h.tlsConfig != nil,
|
||||||
"auth": h.auth.Describe(),
|
"auth": h.auth.Describe(),
|
||||||
"active_clients": h.activeClients.Load(),
|
"active_clients": h.activeClients.Load(),
|
||||||
"buffer_size": h.config.BufferSize,
|
"buffer_size": h.config.BufferSize,
|
||||||
"client_buffer_size": h.config.ClientBufferSize,
|
"uptime_seconds": int(time.Since(h.startTime).Seconds()),
|
||||||
"max_connections": h.config.MaxConnections,
|
|
||||||
"write_timeout_ms": h.config.WriteTimeoutMS,
|
|
||||||
"uptime_seconds": int(time.Since(h.startTime).Seconds()),
|
|
||||||
},
|
},
|
||||||
"endpoints": map[string]string{
|
"endpoints": map[string]string{
|
||||||
"stream": h.config.StreamPath,
|
"stream": h.config.StreamPath,
|
||||||
@@ -514,15 +481,12 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *HTTPSink) GetStats() sink.SinkStats {
|
func (h *HTTPSink) GetStats() sink.SinkStats {
|
||||||
lastProc, _ := h.lastProcessed.Load().(time.Time)
|
lastProc, _ := h.lastProcessed.Load().(time.Time)
|
||||||
details := map[string]any{
|
details := map[string]any{
|
||||||
"host": h.config.Host,
|
"host": h.config.Host,
|
||||||
"port": h.config.Port,
|
"port": h.config.Port,
|
||||||
"buffer_size": h.config.BufferSize,
|
"buffer_size": h.config.BufferSize,
|
||||||
"client_buffer_size": h.config.ClientBufferSize,
|
"tls": h.tlsConfig != nil,
|
||||||
"max_connections": h.config.MaxConnections,
|
"dropped_writes": h.droppedWrites.Load(),
|
||||||
"write_timeout_ms": h.config.WriteTimeoutMS,
|
"rejected_clients": h.rejectedClients.Load(),
|
||||||
"tls": h.tlsConfig != nil,
|
|
||||||
"dropped_writes": h.droppedWrites.Load(),
|
|
||||||
"rejected_clients": h.rejectedClients.Load(),
|
|
||||||
"endpoints": map[string]string{
|
"endpoints": map[string]string{
|
||||||
"stream": h.config.StreamPath,
|
"stream": h.config.StreamPath,
|
||||||
"status": h.config.StatusPath,
|
"status": h.config.StatusPath,
|
||||||
@@ -566,12 +530,6 @@ func (h *HTTPSink) authMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
|
|
||||||
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Allow", http.MethodGet)
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSSE frames a payload per the W3C SSE spec (multi-line safe)
|
// writeSSE frames a payload per the W3C SSE spec (multi-line safe)
|
||||||
func writeSSE(w http.ResponseWriter, payload []byte) error {
|
func writeSSE(w http.ResponseWriter, payload []byte) error {
|
||||||
for _, line := range splitLines(payload) {
|
for _, line := range splitLines(payload) {
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"logwisp/internal/session"
|
|
||||||
"logwisp/internal/sink"
|
|
||||||
|
|
||||||
"github.com/lixenwraith/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStatusReportsQueueAndConnectionBounds(t *testing.T) {
|
|
||||||
manager := session.NewManager(time.Hour)
|
|
||||||
defer manager.Stop()
|
|
||||||
created, err := NewHTTPSinkPlugin(
|
|
||||||
"stream",
|
|
||||||
map[string]any{
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
"port": int64(8081),
|
|
||||||
"buffer_size": int64(4096),
|
|
||||||
"client_buffer_size": int64(512),
|
|
||||||
"max_connections": int64(32),
|
|
||||||
"write_timeout_ms": int64(5000),
|
|
||||||
},
|
|
||||||
log.NewLogger(),
|
|
||||||
session.NewProxy(manager, "stream"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
httpSink, ok := created.(*HTTPSink)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("sink type = %T", created)
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
httpSink.handleStatus(recorder, httptest.NewRequest("GET", "/status", nil))
|
|
||||||
if recorder.Code != 200 {
|
|
||||||
t.Fatalf("status code = %d", recorder.Code)
|
|
||||||
}
|
|
||||||
var response struct {
|
|
||||||
Server map[string]any `json:"server"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for key, want := range map[string]float64{
|
|
||||||
"buffer_size": 4096,
|
|
||||||
"client_buffer_size": 512,
|
|
||||||
"max_connections": 32,
|
|
||||||
"write_timeout_ms": 5000,
|
|
||||||
} {
|
|
||||||
if got := response.Server[key]; got != want {
|
|
||||||
t.Errorf("server.%s = %v, want %v", key, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := httpSink.GetStats()
|
|
||||||
details := stats.Details
|
|
||||||
for key, want := range map[string]int64{
|
|
||||||
"buffer_size": 4096,
|
|
||||||
"client_buffer_size": 512,
|
|
||||||
"max_connections": 32,
|
|
||||||
"write_timeout_ms": 5000,
|
|
||||||
} {
|
|
||||||
if got := details[key]; got != want {
|
|
||||||
t.Errorf("details[%q] = %v, want %v", key, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ sink.Sink = httpSink
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stream carrying nothing still refreshes its session. Log traffic is what
|
|
||||||
// bumps activity otherwise, so a quiet source would idle-expire a healthy client
|
|
||||||
// and the broker would evict it on the next entry.
|
|
||||||
func TestQuietStreamRefreshesItsSession(t *testing.T) {
|
|
||||||
manager := session.NewManager(time.Hour)
|
|
||||||
defer manager.Stop()
|
|
||||||
created, err := NewHTTPSinkPlugin(
|
|
||||||
"stream",
|
|
||||||
map[string]any{"host": "127.0.0.1", "port": int64(18191), "write_timeout_ms": int64(5000)},
|
|
||||||
log.NewLogger(),
|
|
||||||
session.NewProxy(manager, "stream"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
httpSink := created.(*HTTPSink)
|
|
||||||
httpSink.keepalive = 100 * time.Millisecond
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
if err := httpSink.Start(ctx); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer httpSink.Stop()
|
|
||||||
|
|
||||||
resp, err := http.Get("http://127.0.0.1:18191/stream")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
activity := func() time.Time {
|
|
||||||
for _, s := range manager.GetActiveSessions() {
|
|
||||||
return s.LastActivity
|
|
||||||
}
|
|
||||||
t.Fatal("no session for the connected client")
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for activity().IsZero() && time.Now().Before(deadline) {
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
before := activity()
|
|
||||||
|
|
||||||
// No events are sent for several keepalive periods.
|
|
||||||
time.Sleep(350 * time.Millisecond)
|
|
||||||
if after := activity(); !after.After(before) {
|
|
||||||
t.Fatalf("last activity %v did not advance on a silent stream", after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HEAD on the stream path is refused rather than served from the GET pattern:
|
|
||||||
// its body writes are discarded, so the client it would register never reads.
|
|
||||||
func TestHeadOnStreamPathIsRefused(t *testing.T) {
|
|
||||||
manager := session.NewManager(time.Hour)
|
|
||||||
defer manager.Stop()
|
|
||||||
created, err := NewHTTPSinkPlugin(
|
|
||||||
"stream",
|
|
||||||
map[string]any{"host": "127.0.0.1", "port": int64(18192)},
|
|
||||||
log.NewLogger(),
|
|
||||||
session.NewProxy(manager, "stream"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
httpSink := created.(*HTTPSink)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
if err := httpSink.Start(ctx); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer httpSink.Stop()
|
|
||||||
|
|
||||||
resp, err := http.Head("http://127.0.0.1:18192/stream")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
|
||||||
t.Fatalf("HEAD /stream = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
if got := resp.Header.Get("Allow"); got != http.MethodGet {
|
|
||||||
t.Errorf("Allow = %q, want %q", got, http.MethodGet)
|
|
||||||
}
|
|
||||||
if n := manager.GetSessionCount(); n != 0 {
|
|
||||||
t.Errorf("sessions after HEAD = %d, want 0", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"logwisp/internal/config"
|
"logwisp/internal/config"
|
||||||
@@ -62,7 +61,6 @@ const (
|
|||||||
DefaultFileSourcePattern = "*"
|
DefaultFileSourcePattern = "*"
|
||||||
DefaultFileSourceCheckIntervalMS = 100
|
DefaultFileSourceCheckIntervalMS = 100
|
||||||
MinFileSourceCheckIntervalMS = 10
|
MinFileSourceCheckIntervalMS = 10
|
||||||
DefaultFileSourceFrom = "end"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewFileSourcePlugin creates a file source through plugin factory
|
// NewFileSourcePlugin creates a file source through plugin factory
|
||||||
@@ -92,11 +90,6 @@ func NewFileSourcePlugin(
|
|||||||
} else if opts.CheckIntervalMS < MinFileSourceCheckIntervalMS {
|
} else if opts.CheckIntervalMS < MinFileSourceCheckIntervalMS {
|
||||||
return nil, fmt.Errorf("check_interval_ms: must be >= %d", MinFileSourceCheckIntervalMS)
|
return nil, fmt.Errorf("check_interval_ms: must be >= %d", MinFileSourceCheckIntervalMS)
|
||||||
}
|
}
|
||||||
if opts.From == "" {
|
|
||||||
opts.From = DefaultFileSourceFrom
|
|
||||||
} else if err := lconfig.OneOf("start", "end")(opts.From); err != nil {
|
|
||||||
return nil, fmt.Errorf("from: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and return plugin instance
|
// Create and return plugin instance
|
||||||
fs := &FileSource{
|
fs := &FileSource{
|
||||||
@@ -123,9 +116,7 @@ func NewFileSourcePlugin(
|
|||||||
"component", "file_source",
|
"component", "file_source",
|
||||||
"instance_id", id,
|
"instance_id", id,
|
||||||
"directory", opts.Directory,
|
"directory", opts.Directory,
|
||||||
"pattern", opts.Pattern,
|
"pattern", opts.Pattern)
|
||||||
"raw", opts.Raw,
|
|
||||||
"from", opts.From)
|
|
||||||
|
|
||||||
return fs, nil
|
return fs, nil
|
||||||
}
|
}
|
||||||
@@ -270,13 +261,7 @@ func (fs *FileSource) ensureWatcher(path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w := newFileWatcher(path, fs.config.Raw, fs.config.From == "start", fs.publish, fs.logger)
|
w := newFileWatcher(path, fs.publish, fs.logger)
|
||||||
// A rotation renames the file out from under its watcher, so the same inode
|
|
||||||
// reappears here under the archive name. Resume where it was left: from the
|
|
||||||
// start would re-emit every record the file has already delivered.
|
|
||||||
if position, ok := fs.readPosition(path); ok {
|
|
||||||
w.position = position
|
|
||||||
}
|
|
||||||
fs.watchers[path] = w
|
fs.watchers[path] = w
|
||||||
|
|
||||||
fs.logger.Debug("msg", "Created file watcher",
|
fs.logger.Debug("msg", "Created file watcher",
|
||||||
@@ -299,38 +284,10 @@ func (fs *FileSource) ensureWatcher(path string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.removeWatcher(path, w)
|
fs.mu.Lock()
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// readPosition reports how far a running watcher has read the file now at path.
|
|
||||||
// Callers hold fs.mu.
|
|
||||||
func (fs *FileSource) readPosition(path string) (int64, bool) {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
for _, w := range fs.watchers {
|
|
||||||
if position, ok := w.readTo(stat.Ino); ok {
|
|
||||||
return position, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeWatcher removes only the watcher that finished. A deleted file can be
|
|
||||||
// recreated before its old watcher observes stop; in that case ensureWatcher
|
|
||||||
// has already installed a replacement under the same path, which must survive.
|
|
||||||
func (fs *FileSource) removeWatcher(path string, watcher *fileWatcher) {
|
|
||||||
fs.mu.Lock()
|
|
||||||
if fs.watchers[path] == watcher {
|
|
||||||
delete(fs.watchers, path)
|
delete(fs.watchers, path)
|
||||||
}
|
fs.mu.Unlock()
|
||||||
fs.mu.Unlock()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupWatchers stops and removes watchers for files that no longer exist.
|
// cleanupWatchers stops and removes watchers for files that no longer exist.
|
||||||
@@ -404,3 +361,4 @@ func globToRegex(glob string) string {
|
|||||||
regex = strings.ReplaceAll(regex, `\?`, `.`)
|
regex = strings.ReplaceAll(regex, `\?`, `.`)
|
||||||
return "^" + regex + "$"
|
return "^" + regex + "$"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package file
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"logwisp/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStoppedWatcherReturnsNormally(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
||||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
watcher := newFileWatcher(path, true, true, func(_ core.LogEntry) {}, nil)
|
|
||||||
watcher.stop()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := watcher.watch(ctx); err != nil {
|
|
||||||
t.Fatalf("stopped watcher returned error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveWatcherPreservesReplacement(t *testing.T) {
|
|
||||||
oldWatcher := &fileWatcher{}
|
|
||||||
replacement := &fileWatcher{}
|
|
||||||
source := &FileSource{
|
|
||||||
watchers: map[string]*fileWatcher{
|
|
||||||
"session.jsonl": replacement,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
source.removeWatcher("session.jsonl", oldWatcher)
|
|
||||||
if got := source.watchers["session.jsonl"]; got != replacement {
|
|
||||||
t.Fatalf("replacement watcher = %p, want %p", got, replacement)
|
|
||||||
}
|
|
||||||
|
|
||||||
source.removeWatcher("session.jsonl", replacement)
|
|
||||||
if _, exists := source.watchers["session.jsonl"]; exists {
|
|
||||||
t.Fatal("finished watcher was not removed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A rotated file reappears under its archive name with the same inode. Its
|
|
||||||
// replacement watcher resumes where the original stopped, so a `from = "start"`
|
|
||||||
// source does not re-emit every record the file already delivered.
|
|
||||||
func TestRotatedFileResumesInsteadOfReplaying(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
active := filepath.Join(dir, "session.jsonl")
|
|
||||||
if err := os.WriteFile(active, []byte("one\ntwo\n"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
info, err := os.Stat(active)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
inode := info.Sys().(*syscall.Stat_t).Ino
|
|
||||||
|
|
||||||
archive := filepath.Join(dir, "session_260916_120000.jsonl")
|
|
||||||
if err := os.Rename(active, archive); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, watcher := range map[string]*fileWatcher{
|
|
||||||
"still tailing the renamed inode": {inode: inode, position: 8},
|
|
||||||
"already moved on from it": {inode: 99, prevInode: inode, prevPosition: 8},
|
|
||||||
} {
|
|
||||||
source := &FileSource{watchers: map[string]*fileWatcher{active: watcher}}
|
|
||||||
position, ok := source.readPosition(archive)
|
|
||||||
if !ok || position != 8 {
|
|
||||||
t.Errorf("%s: position = %d, ok = %v, want 8, true", name, position, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unrelated := &FileSource{watchers: map[string]*fileWatcher{active: {inode: 99}}}
|
|
||||||
if _, ok := unrelated.readPosition(archive); ok {
|
|
||||||
t.Error("a file no watcher has read was treated as rotated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,6 @@ type WatcherInfo struct {
|
|||||||
type fileWatcher struct {
|
type fileWatcher struct {
|
||||||
directory string
|
directory string
|
||||||
callback func(core.LogEntry)
|
callback func(core.LogEntry)
|
||||||
raw bool
|
|
||||||
position int64
|
position int64
|
||||||
size int64
|
size int64
|
||||||
inode uint64
|
inode uint64
|
||||||
@@ -42,25 +41,17 @@ type fileWatcher struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopped bool
|
stopped bool
|
||||||
rotationSeq int64
|
rotationSeq int64
|
||||||
prevInode uint64
|
|
||||||
prevPosition int64
|
|
||||||
entriesRead atomic.Uint64
|
entriesRead atomic.Uint64
|
||||||
lastReadTime atomic.Value // time.Time
|
lastReadTime atomic.Value // time.Time
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// newFileWatcher creates a new watcher for a specific file path.
|
// newFileWatcher creates a new watcher for a specific file path
|
||||||
// A start position of 0 reads an existing file whole; -1 seeks to its end.
|
func newFileWatcher(directory string, callback func(core.LogEntry), logger *log.Logger) *fileWatcher {
|
||||||
func newFileWatcher(directory string, raw, fromStart bool, callback func(core.LogEntry), logger *log.Logger) *fileWatcher {
|
|
||||||
position := int64(-1)
|
|
||||||
if fromStart {
|
|
||||||
position = 0
|
|
||||||
}
|
|
||||||
w := &fileWatcher{
|
w := &fileWatcher{
|
||||||
directory: directory,
|
directory: directory,
|
||||||
callback: callback,
|
callback: callback,
|
||||||
raw: raw,
|
position: -1,
|
||||||
position: position,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
w.lastReadTime.Store(time.Time{})
|
w.lastReadTime.Store(time.Time{})
|
||||||
@@ -69,8 +60,8 @@ func newFileWatcher(directory string, raw, fromStart bool, callback func(core.Lo
|
|||||||
|
|
||||||
// watch starts the main monitoring loop for the file
|
// watch starts the main monitoring loop for the file
|
||||||
func (w *fileWatcher) watch(ctx context.Context) error {
|
func (w *fileWatcher) watch(ctx context.Context) error {
|
||||||
if err := w.initPosition(); err != nil {
|
if err := w.seekToEnd(); err != nil {
|
||||||
return fmt.Errorf("initPosition failed: %w", err)
|
return fmt.Errorf("seekToEnd failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(core.FileWatcherPollInterval)
|
ticker := time.NewTicker(core.FileWatcherPollInterval)
|
||||||
@@ -82,7 +73,7 @@ func (w *fileWatcher) watch(ctx context.Context) error {
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if w.isStopped() {
|
if w.isStopped() {
|
||||||
return nil
|
return fmt.Errorf("watcher stopped")
|
||||||
}
|
}
|
||||||
if err := w.checkFile(); err != nil {
|
if err := w.checkFile(); err != nil {
|
||||||
// Log error but continue watching
|
// Log error but continue watching
|
||||||
@@ -222,9 +213,6 @@ func (w *fileWatcher) checkFile() error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
w.rotationSeq++
|
w.rotationSeq++
|
||||||
seq := w.rotationSeq
|
seq := w.rotationSeq
|
||||||
// Retained for the source: the renamed file is about to be discovered
|
|
||||||
// under its archive name, and only this says how much of it was read.
|
|
||||||
w.prevInode, w.prevPosition = oldInode, oldPos
|
|
||||||
w.inode = currentInode
|
w.inode = currentInode
|
||||||
w.position = 0 // Reset position on rotation
|
w.position = 0 // Reset position on rotation
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
@@ -309,9 +297,8 @@ func (w *fileWatcher) checkFile() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initPosition records the file's metadata and, unless the watcher was created
|
// seekToEnd sets the initial read position to the end of the file
|
||||||
// to read from the start, sets the initial read position to the end
|
func (w *fileWatcher) seekToEnd() error {
|
||||||
func (w *fileWatcher) initPosition() error {
|
|
||||||
file, err := os.Open(w.directory)
|
file, err := os.Open(w.directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -335,6 +322,8 @@ func (w *fileWatcher) initPosition() error {
|
|||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
// Keep existing position (including 0)
|
||||||
|
// First time initialization seeks to the end of the file
|
||||||
if w.position == -1 {
|
if w.position == -1 {
|
||||||
pos, err := file.Seek(0, io.SeekEnd)
|
pos, err := file.Seek(0, io.SeekEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -352,23 +341,6 @@ func (w *fileWatcher) initPosition() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readTo reports how far this watcher read the given inode: the file it tails
|
|
||||||
// now, or the one a rotation renamed out from under it.
|
|
||||||
func (w *fileWatcher) readTo(inode uint64) (int64, bool) {
|
|
||||||
if inode == 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
switch inode {
|
|
||||||
case w.inode:
|
|
||||||
return w.position, true
|
|
||||||
case w.prevInode:
|
|
||||||
return w.prevPosition, true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isStopped checks if the watcher has been instructed to stop
|
// isStopped checks if the watcher has been instructed to stop
|
||||||
func (w *fileWatcher) isStopped() bool {
|
func (w *fileWatcher) isStopped() bool {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -376,67 +348,36 @@ func (w *fileWatcher) isStopped() bool {
|
|||||||
return w.stopped
|
return w.stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseLine converts a line into an entry, as JSON when nothing would be lost
|
// parseLine attempts to parse a line as JSON, falling back to plain text
|
||||||
func (w *fileWatcher) parseLine(line string) core.LogEntry {
|
func (w *fileWatcher) parseLine(line string) core.LogEntry {
|
||||||
if w.raw {
|
var jsonLog struct {
|
||||||
// Newline restored: sinks write the payload as it stands
|
Time string `json:"time"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"msg"`
|
||||||
|
Fields json.RawMessage `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(line), &jsonLog); err == nil {
|
||||||
|
timestamp, err := time.Parse(time.RFC3339Nano, jsonLog.Time)
|
||||||
|
if err != nil {
|
||||||
|
timestamp = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
return core.LogEntry{
|
return core.LogEntry{
|
||||||
Time: time.Now(),
|
Time: timestamp,
|
||||||
Source: filepath.Base(w.directory),
|
Source: filepath.Base(w.directory),
|
||||||
Level: source.ExtractLogLevel(line),
|
Level: jsonLog.Level,
|
||||||
Message: line + "\n",
|
Message: jsonLog.Message,
|
||||||
|
Fields: jsonLog.Fields,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry, ok := w.parseJSON(line); ok {
|
level := source.ExtractLogLevel(line)
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
return core.LogEntry{
|
return core.LogEntry{
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
Source: filepath.Base(w.directory),
|
Source: filepath.Base(w.directory),
|
||||||
Level: source.ExtractLogLevel(line),
|
Level: level,
|
||||||
Message: line,
|
Message: line,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJSON decodes a line into the entry envelope. A top-level key LogEntry
|
|
||||||
// cannot carry refuses the whole line, so a richer record reaches the pipeline
|
|
||||||
// as text rather than silently reduced to the four keys kept here.
|
|
||||||
func (w *fileWatcher) parseJSON(line string) (core.LogEntry, bool) {
|
|
||||||
if len(line) == 0 || line[0] != '{' {
|
|
||||||
return core.LogEntry{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var obj map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal([]byte(line), &obj); err != nil || len(obj) == 0 {
|
|
||||||
return core.LogEntry{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := core.LogEntry{Time: time.Now(), Source: filepath.Base(w.directory)}
|
|
||||||
for key, val := range obj {
|
|
||||||
var err error
|
|
||||||
switch key {
|
|
||||||
case "time":
|
|
||||||
var ts string
|
|
||||||
if json.Unmarshal(val, &ts) == nil {
|
|
||||||
if t, terr := time.Parse(time.RFC3339Nano, ts); terr == nil {
|
|
||||||
entry.Time = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "level":
|
|
||||||
err = json.Unmarshal(val, &entry.Level)
|
|
||||||
case "msg":
|
|
||||||
err = json.Unmarshal(val, &entry.Message)
|
|
||||||
case "fields":
|
|
||||||
entry.Fields = val
|
|
||||||
default:
|
|
||||||
return core.LogEntry{}, false
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return core.LogEntry{}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry, true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# logwisp file source pass-through test
|
|
||||||
#
|
|
||||||
# file src (raw, from=start) --> raw format --> file sink byte-exact relay
|
|
||||||
# file src (defaults) --> raw format --> file sink no key dropped
|
|
||||||
#
|
|
||||||
# The fixture is a record whose envelope is wider than time/level/msg/fields,
|
|
||||||
# which is what the narrow JSON branch used to reduce to an empty message.
|
|
||||||
#
|
|
||||||
# Usage: ./passthrough-test.sh
|
|
||||||
# Requires: bash 5+, coreutils (timeout). Linux dev host only.
|
|
||||||
|
|
||||||
set -u
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
|
|
||||||
RUN="$SCRIPT_DIR/run/passthrough"
|
|
||||||
|
|
||||||
[[ -x $BIN ]] || { echo "no binary at $BIN; run make build" >&2; exit 1; }
|
|
||||||
|
|
||||||
rm -rf "$RUN"
|
|
||||||
mkdir -p "$RUN/in" "$RUN/out-raw" "$RUN/out-parsed"
|
|
||||||
|
|
||||||
cat > "$RUN/in/wide.jsonl" <<'EOF'
|
|
||||||
{"time":"2026-09-08T21:35:44.178372768-04:00","level":"INFO","sub":"app","run":0,"tick":0,"frame":0,"fields":{"msg":"init begin","mode":"play"}}
|
|
||||||
{"time":"2026-09-08T21:35:44.178581366-04:00","level":"PROC","run":0,"tick":0,"frame":0,"fields":{"seq":1,"seed":1788917744178374836}}
|
|
||||||
plain text line, not JSON at all
|
|
||||||
EOF
|
|
||||||
|
|
||||||
conf() { # sink_dir raw
|
|
||||||
cat <<EOF
|
|
||||||
quiet = true
|
|
||||||
status_reporter = false
|
|
||||||
[logging]
|
|
||||||
output = "stderr"
|
|
||||||
level = "error"
|
|
||||||
[[pipelines]]
|
|
||||||
name = "passthrough"
|
|
||||||
[pipelines.flow.format]
|
|
||||||
type = "raw"
|
|
||||||
sanitizer_policy = "raw"
|
|
||||||
[[pipelines.plugin_sources]]
|
|
||||||
id = "wide"
|
|
||||||
type = "file"
|
|
||||||
[pipelines.plugin_sources.config]
|
|
||||||
directory = "$RUN/in"
|
|
||||||
pattern = "*.jsonl"
|
|
||||||
raw = $2
|
|
||||||
from = "start"
|
|
||||||
[[pipelines.plugin_sinks]]
|
|
||||||
id = "out"
|
|
||||||
type = "file"
|
|
||||||
[pipelines.plugin_sinks.config]
|
|
||||||
directory = "$1"
|
|
||||||
name = "relay"
|
|
||||||
flush_interval_ms = 100
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
conf "$RUN/out-raw" true > "$RUN/raw.toml"
|
|
||||||
conf "$RUN/out-parsed" false > "$RUN/parsed.toml"
|
|
||||||
|
|
||||||
for c in raw parsed; do
|
|
||||||
timeout 5 "$BIN" -c "$RUN/$c.toml" > "$RUN/$c.out" 2>&1
|
|
||||||
done
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
check() { # label condition_result
|
|
||||||
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. raw = true relays the file byte for byte
|
|
||||||
if diff -q "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" > /dev/null; then
|
|
||||||
check "raw = true: output identical to input" 1
|
|
||||||
else
|
|
||||||
check "raw = true: output identical to input" 0
|
|
||||||
diff "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" | head -6
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. the default parse keeps every key, JSON branch refused on the wide envelope
|
|
||||||
n=$(grep -c '"sub":"app"' "$RUN/out-parsed/relay.log")
|
|
||||||
check "defaults: wide envelope reaches the sink whole ($n line(s) carry sub)" $(( n == 1 ))
|
|
||||||
n=$(grep -c '1788917744178374836' "$RUN/out-parsed/relay.log")
|
|
||||||
check "defaults: large integers are not re-encoded through float64 ($n)" $(( n == 1 ))
|
|
||||||
|
|
||||||
echo "================================================================"
|
|
||||||
if (( fail == 0 )); then
|
|
||||||
echo "RESULT: ALL PASS"
|
|
||||||
else
|
|
||||||
echo "RESULT: FAILURES — inspect $RUN/"
|
|
||||||
fi
|
|
||||||
exit "$fail"
|
|
||||||
Reference in New Issue
Block a user