julewire-gcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +40 -0
- data/docs/advanced-configuration.md +25 -0
- data/docs/configuration.md +33 -0
- data/docs/development.md +7 -0
- data/docs/error-reporting.md +31 -0
- data/docs/shape.md +104 -0
- data/docs/trace.md +49 -0
- data/julewire-gcp.gemspec +37 -0
- data/lib/julewire/gcp/destination.rb +22 -0
- data/lib/julewire/gcp/execution_payload.rb +41 -0
- data/lib/julewire/gcp/formatter.rb +238 -0
- data/lib/julewire/gcp/formatter_options.rb +59 -0
- data/lib/julewire/gcp/http_request_fields.rb +49 -0
- data/lib/julewire/gcp/label_formatter.rb +59 -0
- data/lib/julewire/gcp/log_decoder.rb +64 -0
- data/lib/julewire/gcp/log_encoder.rb +19 -0
- data/lib/julewire/gcp/source_location.rb +53 -0
- data/lib/julewire/gcp/source_location_options.rb +33 -0
- data/lib/julewire/gcp/stack_trace.rb +47 -0
- data/lib/julewire/gcp/trace_context/traceparent.rb +62 -0
- data/lib/julewire/gcp/trace_context.rb +104 -0
- data/lib/julewire/gcp/version.rb +7 -0
- data/lib/julewire/gcp.rb +55 -0
- data/lib/julewire-gcp.rb +3 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7770e24c27f9957004b7e38a1fc5b49161a07f65173fe687bea6a8102f3796d5
|
|
4
|
+
data.tar.gz: 37376ea4aa3f56d0488a15a36d5d2a9e82a4244c5accd18fca224c50a9e070f3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 853abdb1eee58dfb2d61178f50ec572df0504131d2af476abd7fd9a4b8b141249dbe82b585b0880a8c17d882b110fdd0be0e3fecce74cfe2b2441afa02f6c79c
|
|
7
|
+
data.tar.gz: 97b6621b589e538254db6455522270860d1b643a6f7f730f10f9b448d0d534b0abf4d04129bb028707aef90c739978180a052e01b996d84d203eccdd4dd86d33
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Grebennik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Julewire GCP
|
|
2
|
+
|
|
3
|
+
Google Cloud Logging structured JSON formatter and direct-output destination
|
|
4
|
+
for Julewire records.
|
|
5
|
+
|
|
6
|
+
It does not own queues, batching, retries, or network transport.
|
|
7
|
+
|
|
8
|
+
## Quickstart
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "julewire-gcp"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
Julewire.configure do |config|
|
|
16
|
+
config.destinations.use(
|
|
17
|
+
:gcp,
|
|
18
|
+
formatter: Julewire::GCP::Formatter.new(project_id: "my-project"),
|
|
19
|
+
output: $stdout
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Default shape:
|
|
25
|
+
|
|
26
|
+
- Cloud Logging special fields: `severity`, `message`, `time`, `httpRequest`,
|
|
27
|
+
labels, operation, source location, trace, span, and trace sampled
|
|
28
|
+
- remaining Julewire fields in JSON payload
|
|
29
|
+
- records with neutral HTTP attributes mapped to `httpRequest`
|
|
30
|
+
- stack traces promoted for Error Reporting when core-shaped errors include
|
|
31
|
+
backtrace lines
|
|
32
|
+
|
|
33
|
+
## Docs
|
|
34
|
+
|
|
35
|
+
- [Configuration](docs/configuration.md)
|
|
36
|
+
- [Advanced Configuration](docs/advanced-configuration.md)
|
|
37
|
+
- [Shape](docs/shape.md)
|
|
38
|
+
- [Trace](docs/trace.md)
|
|
39
|
+
- [Error Reporting](docs/error-reporting.md)
|
|
40
|
+
- [Development](docs/development.md)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Advanced Configuration
|
|
2
|
+
|
|
3
|
+
| Option | Default | Purpose |
|
|
4
|
+
| --- | --- | --- |
|
|
5
|
+
| `trace_headers_paths` | `carry.http.request_headers`, then secondary paths | Request trace-header paths. |
|
|
6
|
+
| `trace_id_path` | `nil` | Explicit path to trace id. |
|
|
7
|
+
| `span_id_path` | `nil` | Explicit path to span id. |
|
|
8
|
+
| `trace_sampled_path` | `nil` | Explicit path to sampling flag. |
|
|
9
|
+
| `label_formatter` | `nil` | Custom label formatter. |
|
|
10
|
+
| `label_options` | `{}` | Options passed to the default label formatter. |
|
|
11
|
+
|
|
12
|
+
Use explicit trace paths when trace facts are already normalized into record
|
|
13
|
+
fields and no header parsing is needed:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
Julewire::GCP::Formatter.new(
|
|
17
|
+
project_id: "my-project",
|
|
18
|
+
trace_id_path: %i[attributes trace_id],
|
|
19
|
+
span_id_path: %i[attributes span_id],
|
|
20
|
+
trace_sampled_path: %i[attributes trace_sampled]
|
|
21
|
+
)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use `label_formatter` only when the default label limits or key formatting are
|
|
25
|
+
not enough.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## Default Path
|
|
4
|
+
|
|
5
|
+
| Option | Default | Purpose |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `project_id` | `nil` | Expand bare trace IDs to Cloud Trace resource names. |
|
|
8
|
+
| `service_context` | `nil` | Add Error Reporting service/version context. |
|
|
9
|
+
| `max_record_bytes` | `256 KiB` | Destination record-size cap from `Julewire::GCP::Destination`. |
|
|
10
|
+
|
|
11
|
+
## Common Knobs
|
|
12
|
+
|
|
13
|
+
| Option | Default | Purpose |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `operation_producer` | `nil` | Override `logging.googleapis.com/operation.producer`. |
|
|
16
|
+
| `max_labels` | `64` | Maximum Cloud Logging labels emitted. |
|
|
17
|
+
| `max_label_key_bytes` | `512` | Maximum label key byte size. |
|
|
18
|
+
| `max_label_value_bytes` | `65_536` | Maximum label value byte size. |
|
|
19
|
+
|
|
20
|
+
Oversized label values are truncated. Oversized label keys are dropped because
|
|
21
|
+
Cloud Logging rejects invalid label keys.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
Julewire::GCP::Formatter.new(
|
|
27
|
+
project_id: "my-project",
|
|
28
|
+
service_context: { service: "web", version: "2026-05-31" }
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`Julewire::GCP::Destination` uses `Julewire::GCP::RECOMMENDED_MAX_RECORD_BYTES`
|
|
33
|
+
as a conservative `max_record_bytes` default.
|
data/docs/development.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Error Reporting
|
|
2
|
+
|
|
3
|
+
When a record contains an `error` hash with backtrace lines, the formatter
|
|
4
|
+
promotes those lines to top-level JSON payload field `stack_trace` for Cloud
|
|
5
|
+
Logging/Error Reporting ingestion.
|
|
6
|
+
|
|
7
|
+
The stack trace starts with the exception summary, includes nested causes, and
|
|
8
|
+
respects core's `error_backtrace_lines` setting. If
|
|
9
|
+
`error_backtrace_lines = 0`, core-shaped errors have no backtrace and the
|
|
10
|
+
formatter emits no `stack_trace`.
|
|
11
|
+
|
|
12
|
+
When `stack_trace` is promoted, nested `julewire.error.backtrace` fields are
|
|
13
|
+
removed to avoid carrying the same stack twice in one log entry. Exception
|
|
14
|
+
class, message, and cause metadata remain in `julewire.error`.
|
|
15
|
+
|
|
16
|
+
If the record has no message and no HTTP-derived message can be derived, the
|
|
17
|
+
top-level `message` uses the exception summary, such as
|
|
18
|
+
`RuntimeError: boom`.
|
|
19
|
+
|
|
20
|
+
When no explicit `payload.gcp.source_location` is present, the formatter first
|
|
21
|
+
uses neutral `code.*` fields from the record's `neutral` section, then infers
|
|
22
|
+
`logging.googleapis.com/sourceLocation` from the first error backtrace frame.
|
|
23
|
+
|
|
24
|
+
References:
|
|
25
|
+
|
|
26
|
+
- Google Cloud Logging structured logging:
|
|
27
|
+
https://docs.cloud.google.com/logging/docs/structured-logging
|
|
28
|
+
- Google Cloud Logging `LogEntry`:
|
|
29
|
+
https://docs.cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
|
|
30
|
+
- Google Cloud Error Reporting log formatting:
|
|
31
|
+
https://docs.cloud.google.com/error-reporting/docs/formatting-error-messages
|
data/docs/shape.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Shape
|
|
2
|
+
|
|
3
|
+
The formatter maps Julewire records to the special JSON fields recognized by
|
|
4
|
+
Cloud Logging agents:
|
|
5
|
+
|
|
6
|
+
- `severity`
|
|
7
|
+
- `message`
|
|
8
|
+
- `time`
|
|
9
|
+
- `httpRequest`
|
|
10
|
+
- `logging.googleapis.com/labels`
|
|
11
|
+
- `logging.googleapis.com/operation`
|
|
12
|
+
- `logging.googleapis.com/sourceLocation`
|
|
13
|
+
- `logging.googleapis.com/trace`
|
|
14
|
+
- `logging.googleapis.com/spanId`
|
|
15
|
+
- `logging.googleapis.com/trace_sampled`
|
|
16
|
+
|
|
17
|
+
The remaining non-empty Julewire data stays queryable in the JSON payload:
|
|
18
|
+
|
|
19
|
+
- `payload`
|
|
20
|
+
- `attributes`
|
|
21
|
+
- `serviceContext` when configured
|
|
22
|
+
- `julewire.kind`
|
|
23
|
+
- `julewire.event`
|
|
24
|
+
- `julewire.logger`
|
|
25
|
+
- `julewire.source`
|
|
26
|
+
- `julewire.execution`
|
|
27
|
+
- `julewire.context`
|
|
28
|
+
- `julewire.error`
|
|
29
|
+
- `julewire.metrics`
|
|
30
|
+
|
|
31
|
+
Records are mapped into `httpRequest` when they include provider-neutral HTTP
|
|
32
|
+
fields in the record's `neutral` section:
|
|
33
|
+
|
|
34
|
+
- `http.request.method`
|
|
35
|
+
- `url.full`
|
|
36
|
+
- `url.path`
|
|
37
|
+
- `http.response.status_code`
|
|
38
|
+
- `user_agent.original`
|
|
39
|
+
- `client.address`
|
|
40
|
+
- `http.response.body.size`
|
|
41
|
+
|
|
42
|
+
`httpRequest.latency` comes from `metrics.duration_ms` when present.
|
|
43
|
+
|
|
44
|
+
The formatter reads these core neutral HTTP attributes to build `httpRequest`.
|
|
45
|
+
Integration-specific attribute namespaces stay queryable.
|
|
46
|
+
|
|
47
|
+
Records are mapped into `logging.googleapis.com/sourceLocation` when they carry
|
|
48
|
+
neutral `code.file.path`, `code.line.number`, or `code.function.name` attributes
|
|
49
|
+
in the record's `neutral` section. Explicit `payload.gcp.source_location` wins.
|
|
50
|
+
|
|
51
|
+
When a record has HTTP method, URL or path, and status but no explicit message,
|
|
52
|
+
the formatter emits a concise HTTP-derived message such as
|
|
53
|
+
`GET /orders -> 200 in 24.1ms`.
|
|
54
|
+
|
|
55
|
+
Request summaries are marked as `logging.googleapis.com/operation.last = true`.
|
|
56
|
+
The formatter does not infer `operation.first`; mark that specific record
|
|
57
|
+
explicitly when it matters:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Julewire.with_execution(type: :job, id: "job-1") do
|
|
61
|
+
Julewire.emit(
|
|
62
|
+
event: "job.started",
|
|
63
|
+
source: "worker",
|
|
64
|
+
payload: Julewire::GCP.operation(first: true)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
GCP output keeps public execution fields under `julewire.execution`.
|
|
70
|
+
Lineage internals are omitted. Execution fields promoted to GCP-native fields,
|
|
71
|
+
such as `logging.googleapis.com/operation.id` or configured trace paths, are
|
|
72
|
+
not duplicated there.
|
|
73
|
+
|
|
74
|
+
For records without an execution, provide an operation id:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
Julewire.emit(
|
|
78
|
+
event: "script.started",
|
|
79
|
+
source: "script",
|
|
80
|
+
payload: Julewire::GCP.operation(
|
|
81
|
+
id: "nightly-import-20260531",
|
|
82
|
+
producer: "script",
|
|
83
|
+
first: true
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The formatter consumes `payload.gcp.operation` and
|
|
89
|
+
`payload.gcp.source_location` as control metadata and removes those control keys
|
|
90
|
+
from emitted application payload.
|
|
91
|
+
|
|
92
|
+
Add source-location metadata when the application has a meaningful code location
|
|
93
|
+
to attach to a record:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
Julewire.emit(
|
|
97
|
+
event: "import.started",
|
|
98
|
+
payload: Julewire::GCP.source_location(
|
|
99
|
+
file: "app/jobs/import_job.rb",
|
|
100
|
+
line: 42,
|
|
101
|
+
function: "ImportJob#perform"
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
```
|
data/docs/trace.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Trace
|
|
2
|
+
|
|
3
|
+
The formatter exposes the trace header names it needs as:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Julewire::GCP::CARRY_REQUEST_HEADERS
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Put selected headers into Julewire carry before emitting records:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
headers = {
|
|
13
|
+
"traceparent" => "00-...",
|
|
14
|
+
"tracestate" => "...",
|
|
15
|
+
"x-cloud-trace-context" => "..."
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Julewire.with_execution(type: :request, id: "request-1") do
|
|
19
|
+
Julewire.carry.add(http: { request_headers: headers })
|
|
20
|
+
Julewire.emit(message: "handled")
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
By default the formatter looks for request headers at
|
|
25
|
+
`carry.http.request_headers`, then checks `payload.request_headers` and
|
|
26
|
+
`context.request_headers`. It understands W3C `traceparent` first, then Google
|
|
27
|
+
`x-cloud-trace-context`.
|
|
28
|
+
|
|
29
|
+
`tracestate` is carried with `traceparent`, but Cloud Logging special fields do
|
|
30
|
+
not use it directly. If `project_id` is configured, a bare trace ID is expanded
|
|
31
|
+
to:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
projects/[PROJECT-ID]/traces/[TRACE-ID]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Already-expanded trace resource names are left unchanged.
|
|
38
|
+
|
|
39
|
+
You can point trace extraction at different record paths when another
|
|
40
|
+
integration or processor stores trace facts elsewhere:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
Julewire::GCP::Formatter.new(
|
|
44
|
+
project_id: "my-project",
|
|
45
|
+
trace_id_path: %i[context trace_id],
|
|
46
|
+
span_id_path: %i[context span_id],
|
|
47
|
+
trace_sampled_path: %i[context trace_sampled]
|
|
48
|
+
)
|
|
49
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/julewire/gcp/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "julewire-gcp"
|
|
7
|
+
spec.version = Julewire::GCP::VERSION
|
|
8
|
+
spec.authors = ["Alexander Grebennik"]
|
|
9
|
+
spec.email = ["slbug@users.noreply.github.com", "sl.bug.sl@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Google Cloud Logging formatter for Julewire."
|
|
12
|
+
spec.description = "Google Cloud Logging structured JSON formatter for Julewire records."
|
|
13
|
+
spec.homepage = "https://github.com/slbug/julewire"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.4"
|
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["source_code_uri"] = "https://github.com/slbug/julewire/tree/main/gems/gcp"
|
|
18
|
+
spec.metadata["changelog_uri"] = "https://github.com/slbug/julewire/blob/main/gems/gcp/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
21
|
+
|
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
|
23
|
+
Dir[
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"LICENSE.txt",
|
|
26
|
+
"README.md",
|
|
27
|
+
"docs/**/*.md",
|
|
28
|
+
"julewire-gcp.gemspec",
|
|
29
|
+
"lib/**/*.rb"
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
spec.executables = []
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
spec.add_dependency "julewire-core", ">= 1.0"
|
|
36
|
+
spec.add_dependency "zeitwerk", ">= 2.8.1"
|
|
37
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module GCP
|
|
5
|
+
class Destination < Julewire::Core::Destinations::Destination
|
|
6
|
+
def initialize(output:, name: :gcp, formatter: nil, encoder: Julewire::JsonEncoder.new,
|
|
7
|
+
max_record_bytes: DEFAULT_MAX_RECORD_BYTES, close_output: false, on_drop: nil,
|
|
8
|
+
on_failure: nil)
|
|
9
|
+
super(
|
|
10
|
+
name: name,
|
|
11
|
+
close_output: close_output,
|
|
12
|
+
encoder: encoder,
|
|
13
|
+
formatter: formatter || Formatter.new,
|
|
14
|
+
max_record_bytes: max_record_bytes,
|
|
15
|
+
on_drop: on_drop,
|
|
16
|
+
on_failure: on_failure,
|
|
17
|
+
output: output
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module GCP
|
|
5
|
+
class ExecutionPayload
|
|
6
|
+
def initialize(trace_id_path:, span_id_path:, trace_sampled_path:)
|
|
7
|
+
@trace_keys = trace_execution_keys(trace_id_path, span_id_path, trace_sampled_path)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(record, operation_options:)
|
|
11
|
+
execution = record.fetch(:execution)
|
|
12
|
+
output = nil
|
|
13
|
+
execution.each do |key, value|
|
|
14
|
+
next if promoted_key?(key, value, operation_options)
|
|
15
|
+
|
|
16
|
+
(output ||= {})[key] = value
|
|
17
|
+
end
|
|
18
|
+
output
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def promoted_key?(key, value, operation_options)
|
|
24
|
+
return true if key == :id && !operation_options[:id]
|
|
25
|
+
|
|
26
|
+
@trace_keys.key?(key) && !Core::Integration::Values::Read.blank?(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def trace_execution_keys(*paths)
|
|
30
|
+
paths.map { trace_execution_key(it) }.to_h { [it, true] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def trace_execution_key(path)
|
|
34
|
+
return unless path&.length == 2
|
|
35
|
+
return unless path.fetch(0) == :execution
|
|
36
|
+
|
|
37
|
+
path.fetch(1)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module GCP
|
|
5
|
+
class Formatter
|
|
6
|
+
SEVERITIES = {
|
|
7
|
+
debug: "DEBUG",
|
|
8
|
+
info: "INFO",
|
|
9
|
+
warn: "WARNING",
|
|
10
|
+
error: "ERROR",
|
|
11
|
+
fatal: "CRITICAL",
|
|
12
|
+
unknown: "DEFAULT"
|
|
13
|
+
}.freeze
|
|
14
|
+
EMPTY_HASH = {}.freeze
|
|
15
|
+
TRUE_VALUES = %w[true 1 yes].freeze
|
|
16
|
+
private_constant :EMPTY_HASH, :TRUE_VALUES
|
|
17
|
+
|
|
18
|
+
def initialize(project_id: nil,
|
|
19
|
+
operation_producer: nil,
|
|
20
|
+
service_context: nil,
|
|
21
|
+
trace_headers_paths: [
|
|
22
|
+
%i[carry http request_headers],
|
|
23
|
+
%i[payload request_headers],
|
|
24
|
+
%i[context request_headers]
|
|
25
|
+
],
|
|
26
|
+
**options)
|
|
27
|
+
FormatterOptions.validate!(options)
|
|
28
|
+
@project_id = project_id
|
|
29
|
+
@operation_producer = operation_producer
|
|
30
|
+
@service_context = frozen_service_context(service_context)
|
|
31
|
+
@trace_headers_paths = FormatterOptions.trace_headers_paths(trace_headers_paths)
|
|
32
|
+
@label_formatter = FormatterOptions.label_formatter(options)
|
|
33
|
+
@trace_id_path = FormatterOptions.trace_value_path(options[:trace_id_path])
|
|
34
|
+
@span_id_path = FormatterOptions.trace_value_path(options[:span_id_path])
|
|
35
|
+
@trace_sampled_path = FormatterOptions.trace_value_path(options[:trace_sampled_path])
|
|
36
|
+
@execution_payload = ExecutionPayload.new(
|
|
37
|
+
trace_id_path: @trace_id_path,
|
|
38
|
+
span_id_path: @span_id_path,
|
|
39
|
+
trace_sampled_path: @trace_sampled_path
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(record)
|
|
44
|
+
Julewire::Record.validate_normalized!(record)
|
|
45
|
+
|
|
46
|
+
error = record.fetch(:error)
|
|
47
|
+
operation_options = operation_options(record)
|
|
48
|
+
neutral_attributes = Core::Fields::AttributeKeys.from(record.fetch(:neutral))
|
|
49
|
+
source_location_options = SourceLocationOptions.call(record, neutral_attributes)
|
|
50
|
+
entry = {
|
|
51
|
+
"severity" => severity(record.fetch(:severity)),
|
|
52
|
+
"time" => record.fetch(:timestamp)
|
|
53
|
+
}
|
|
54
|
+
append_log_field(entry, "message", Core::Records::DisplayMessage.call(record))
|
|
55
|
+
entry.tap do |log_entry|
|
|
56
|
+
append_special_fields(
|
|
57
|
+
log_entry,
|
|
58
|
+
record,
|
|
59
|
+
error: error,
|
|
60
|
+
operation_options: operation_options,
|
|
61
|
+
source_location_options: source_location_options,
|
|
62
|
+
neutral_attributes: neutral_attributes
|
|
63
|
+
)
|
|
64
|
+
append_payload_fields(log_entry, record, error: error, operation_options: operation_options)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def frozen_service_context(value) = value ? Core::Fields::FieldSet.frozen_copy(value) : nil
|
|
71
|
+
|
|
72
|
+
def append_special_fields(entry, record, error:, operation_options:, source_location_options:,
|
|
73
|
+
neutral_attributes:)
|
|
74
|
+
trace_context = trace_context(record)
|
|
75
|
+
append_log_field(entry, "httpRequest", HttpRequestFields.http_request(record, neutral_attributes))
|
|
76
|
+
append_log_field(entry, "logging.googleapis.com/labels", labels(record))
|
|
77
|
+
append_log_field(entry, "logging.googleapis.com/operation", operation(record, operation_options))
|
|
78
|
+
append_log_field(
|
|
79
|
+
entry,
|
|
80
|
+
"logging.googleapis.com/sourceLocation",
|
|
81
|
+
source_location(error, source_location_options)
|
|
82
|
+
)
|
|
83
|
+
append_log_field(entry, "logging.googleapis.com/trace", trace(record, trace_context))
|
|
84
|
+
append_log_field(
|
|
85
|
+
entry,
|
|
86
|
+
"logging.googleapis.com/spanId",
|
|
87
|
+
trace_value(record, trace_context, @span_id_path, :span_id)
|
|
88
|
+
)
|
|
89
|
+
append_log_field(
|
|
90
|
+
entry,
|
|
91
|
+
"logging.googleapis.com/trace_sampled",
|
|
92
|
+
trace_value(record, trace_context, @trace_sampled_path, :trace_sampled)
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def append_payload_fields(entry, record, error:, operation_options:)
|
|
97
|
+
stack_trace = stack_trace(error)
|
|
98
|
+
append_log_field(entry, JULEWIRE_PAYLOAD_FIELD, julewire_payload(record,
|
|
99
|
+
error: error,
|
|
100
|
+
operation_options: operation_options,
|
|
101
|
+
stack_trace: stack_trace))
|
|
102
|
+
append_log_field(entry, "attributes", attributes_payload(record))
|
|
103
|
+
append_log_field(entry, "payload", application_payload(record))
|
|
104
|
+
append_log_field(entry, "stack_trace", stack_trace)
|
|
105
|
+
append_log_field(entry, "serviceContext", @service_context)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def append_log_field(entry, key, value)
|
|
109
|
+
return if value.nil?
|
|
110
|
+
return if (value.is_a?(Hash) || value.is_a?(Array)) && value.empty?
|
|
111
|
+
|
|
112
|
+
entry[key] = value
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def severity(value) = SEVERITIES.fetch(value)
|
|
117
|
+
|
|
118
|
+
def labels(record)
|
|
119
|
+
@label_formatter.call(record.fetch(:labels))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def operation(record, options)
|
|
123
|
+
execution = record.fetch(:execution)
|
|
124
|
+
id = options[:id] || execution[:id] || record.lineage.root_reference&.fetch(:id, nil)
|
|
125
|
+
return unless id
|
|
126
|
+
|
|
127
|
+
operation = {
|
|
128
|
+
"id" => id.to_s
|
|
129
|
+
}
|
|
130
|
+
append_log_field(operation, "producer", operation_producer(record, options))
|
|
131
|
+
operation["first"] = true if true_value?(options[:first])
|
|
132
|
+
operation["last"] = true if record.fetch(:kind) == :summary || true_value?(options[:last])
|
|
133
|
+
operation
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def operation_options(record)
|
|
137
|
+
options = record.dig(:payload, :gcp, :operation)
|
|
138
|
+
options.is_a?(Hash) ? options : EMPTY_HASH
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def operation_producer(record, options)
|
|
142
|
+
options[:producer] || @operation_producer || record[:source] || record[:logger]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def source_location(error, options)
|
|
146
|
+
return SourceLocation.call(options) unless options.empty?
|
|
147
|
+
return unless error.is_a?(Hash) && !error.empty?
|
|
148
|
+
|
|
149
|
+
SourceLocation.from_error(error)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def trace(record, trace_context)
|
|
153
|
+
value = trace_value(record, trace_context, @trace_id_path, :trace_id)
|
|
154
|
+
return unless value
|
|
155
|
+
|
|
156
|
+
trace = value.to_s
|
|
157
|
+
return trace if trace.start_with?("projects/") || @project_id.nil?
|
|
158
|
+
|
|
159
|
+
"projects/#{@project_id}/traces/#{trace}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def trace_context(record)
|
|
163
|
+
@trace_headers_paths.each do |path|
|
|
164
|
+
context = TraceContext.extract(Core::Integration::Values::Read.path_value(record, path))
|
|
165
|
+
return context unless context.empty?
|
|
166
|
+
end
|
|
167
|
+
EMPTY_HASH
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def trace_value(record, trace_context, path, key)
|
|
171
|
+
value = Core::Integration::Values::Read.path_value(record, path) if path
|
|
172
|
+
Core::Integration::Values::Read.blank?(value) ? trace_context[key] : value
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def application_payload(record)
|
|
176
|
+
payload = record.fetch(:payload)
|
|
177
|
+
control = payload[:gcp]
|
|
178
|
+
remove_gcp_control_payload(payload, control)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def attributes_payload(record)
|
|
182
|
+
record.fetch(:attributes)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def remove_gcp_control_payload(payload, control)
|
|
186
|
+
return payload unless control.is_a?(Hash) && gcp_control_payload?(control)
|
|
187
|
+
|
|
188
|
+
cleaned_payload = payload.dup
|
|
189
|
+
cleaned_control = control.dup
|
|
190
|
+
cleaned_control.delete(:operation)
|
|
191
|
+
cleaned_control.delete(:source_location)
|
|
192
|
+
if cleaned_control.empty?
|
|
193
|
+
cleaned_payload.delete(:gcp)
|
|
194
|
+
else
|
|
195
|
+
cleaned_payload[:gcp] = cleaned_control
|
|
196
|
+
end
|
|
197
|
+
cleaned_payload
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def gcp_control_payload?(control)
|
|
201
|
+
control.key?(:operation) || control.key?(:source_location)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def julewire_payload(record, error:, operation_options:, stack_trace: nil)
|
|
205
|
+
{}.tap do |payload|
|
|
206
|
+
append_log_field(payload, :kind, record.fetch(:kind))
|
|
207
|
+
append_log_field(payload, :event, record.fetch(:event))
|
|
208
|
+
append_log_field(payload, :logger, record.fetch(:logger))
|
|
209
|
+
append_log_field(payload, :source, record.fetch(:source))
|
|
210
|
+
append_log_field(
|
|
211
|
+
payload,
|
|
212
|
+
:execution,
|
|
213
|
+
@execution_payload.call(record, operation_options: operation_options)
|
|
214
|
+
)
|
|
215
|
+
append_log_field(payload, :context, record.fetch(:context))
|
|
216
|
+
append_log_field(payload, :error, julewire_error(error, stack_trace: stack_trace))
|
|
217
|
+
append_log_field(payload, :metrics, record[:metrics])
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def julewire_error(error, stack_trace:)
|
|
222
|
+
return error unless error.is_a?(Hash) && stack_trace
|
|
223
|
+
|
|
224
|
+
StackTrace.remove_backtraces(error)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def stack_trace(error)
|
|
228
|
+
return unless error.is_a?(Hash) && !error.empty?
|
|
229
|
+
|
|
230
|
+
StackTrace.call(error)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def true_value?(value)
|
|
234
|
+
TRUE_VALUES.include?(value.to_s.downcase)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|