Skip to content

Commit 4b948bb

Browse files
authored
Merge pull request #315 from andersonkrs/master
Add appender for Grafana Loki using the HTTP push API
2 parents 738b91c + 8dc26b5 commit 4b948bb

File tree

7 files changed

+577
-0
lines changed

7 files changed

+577
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
88
## [4.17.0]
99

1010
- Correct `source_code_uri` URL
11+
- Add appender for Grafana Loki using the HTTP push API.
1112
- Add :notime as a time_format for Formatters
1213
- Fix #316: syslog messages contains two timestamps
1314
- Add appender for CloudWatch Logs

docs/appenders.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Log messages can be written to one or more of the following destinations at the
3333
* Honeybadger Insights
3434
* CloudWatch Logs
3535
* Logger, log4r, etc.
36+
* Grafana Loki
3637

3738
To ensure no log messages are lost it is recommend to use TCP over UDP for logging purposes.
3839
Due to the architecture of Semantic Logger any performance difference between TCP and UDP will not
@@ -758,6 +759,22 @@ SemanticLogger.add_appender(appender: :honeybadger_insights)
758759

759760
Both appenders use the Honeybadger [gem configuration](https://docs.honeybadger.io/lib/ruby/gem-reference/configuration/).
760761

762+
### Grafana Loki
763+
764+
Sends log messages to [Grafana Loki](https://grafana.com/docs/loki) using its [HTTP push API](https://grafana.com/docs/loki/latest/reference/loki-http-api/#ingest-logs)
765+
766+
```ruby
767+
SemanticLogger.add_appender(
768+
appender: :loki,
769+
url: "https://logs-prod-001.grafana.net",
770+
username: "grafana_username",
771+
password: "grafana_token_here",
772+
compress: true
773+
)
774+
```
775+
776+
Configure the URL, username and password according to your Grafana Loki instance. The `compress` option can be set to `true` to compress the log messages.
777+
761778
### CloudWatch Logs
762779

763780
Forward all log messages to CloudWatch Logs.

lib/semantic_logger/appender.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ module Appender
2727
autoload :Udp, "semantic_logger/appender/udp"
2828
autoload :Wrapper, "semantic_logger/appender/wrapper"
2929
autoload :SentryRuby, "semantic_logger/appender/sentry_ruby"
30+
autoload :Loki, "semantic_logger/appender/loki"
3031
# @formatter:on
3132

3233
# Returns [SemanticLogger::Subscriber] appender for the supplied options
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Forward application metrics to a Loki instance using HTTP push API
2+
#
3+
# Example:
4+
# SemanticLogger.add_appender(
5+
# appender: :loki,
6+
# url: "https://logs-prod-001.grafana.net",
7+
# username: "grafana_username",
8+
# password: "grafana_token_here",
9+
# compress: true
10+
# )
11+
module SemanticLogger
12+
module Appender
13+
class Loki < SemanticLogger::Appender::Http
14+
INGESTION_PATH = "loki/api/v1/push".freeze
15+
16+
# Create Grafana Loki appender.
17+
#
18+
# Parameters:
19+
# filter: [Regexp|Proc]
20+
# RegExp: Only include log messages where the class name matches the supplied
21+
# regular expression. All other messages will be ignored.
22+
# Proc: Only include log messages where the supplied Proc returns true.
23+
# The Proc must return true or false.
24+
#
25+
# host: [String]
26+
# Name of this host to send as a dimension.
27+
# Default: SemanticLogger.host
28+
#
29+
# application: [String]
30+
# Name of this application to send as a dimension.
31+
# Default: SemanticLogger.application
32+
#
33+
# url: [String]
34+
# Define the loki instance URL.
35+
# Example: https://logs-prod-999.grafana.net
36+
# Default: nil
37+
def initialize(url: nil,
38+
formatter: SemanticLogger::Formatters::Loki.new,
39+
header: {"Content-Type" => "application/json"},
40+
path: INGESTION_PATH,
41+
**args,
42+
&block)
43+
44+
super(url: "#{url}/#{path}", formatter: formatter, header: header, **args, &block)
45+
end
46+
47+
def log(log)
48+
message = formatter.call(log, self)
49+
puts message
50+
logger.trace(message)
51+
post(message)
52+
end
53+
54+
# Logs in batches
55+
def batch(logs)
56+
message = formatter.batch(logs, self)
57+
logger.trace(message)
58+
post(message)
59+
end
60+
end
61+
end
62+
end

lib/semantic_logger/formatters.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module Formatters
1313
autoload :Logfmt, "semantic_logger/formatters/logfmt"
1414
autoload :SyslogCee, "semantic_logger/formatters/syslog_cee"
1515
autoload :NewRelicLogs, "semantic_logger/formatters/new_relic_logs"
16+
autoload :Loki, "semantic_logger/formatters/loki"
1617

1718
# Return formatter that responds to call.
1819
#
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
require "json"
2+
3+
module SemanticLogger
4+
module Formatters
5+
class Loki < Base
6+
attr_accessor :stream, :payload_value
7+
8+
# Returns [String] a single JSON log
9+
def call(log, logger)
10+
self.logger = logger
11+
self.log = log
12+
13+
{streams: [build_stream]}.to_json
14+
end
15+
16+
# Returns [String] a JSON batch of logs
17+
def batch(logs, logger)
18+
self.logger = logger
19+
20+
streams = logs.map do |log|
21+
self.log = log
22+
build_stream
23+
end
24+
25+
{streams: streams}.to_json
26+
end
27+
28+
private
29+
30+
def build_stream
31+
self.stream = {stream: {pid: pid}, values: [[]]}
32+
33+
application
34+
environment
35+
host
36+
level
37+
thread
38+
tags
39+
named_tags
40+
context
41+
time
42+
message
43+
payload
44+
metric
45+
duration
46+
exception
47+
48+
stream[:values][0] << payload_value
49+
stream
50+
end
51+
52+
def host
53+
stream[:stream][:host] = logger.host if log_host && logger.host.to_s
54+
end
55+
56+
def application
57+
stream[:stream][:application] = logger.application if log_application && logger&.application
58+
end
59+
60+
def environment
61+
stream[:stream][:environment] = logger.environment if log_environment && logger&.environment
62+
end
63+
64+
def level
65+
stream[:stream][:level] = log.level
66+
end
67+
68+
def thread
69+
stream[:stream][:thread] = log.thread_name if log.thread_name
70+
end
71+
72+
def tags
73+
stream[:stream][:tags] = log.tags if log.tags.respond_to?(:empty?) && !log.tags.empty?
74+
end
75+
76+
def named_tags
77+
stream[:stream].merge!(log.named_tags) if log.named_tags.respond_to?(:empty?) && !log.named_tags.empty?
78+
end
79+
80+
def context
81+
return unless log.context && !log.context.empty?
82+
83+
log.context.each do |key, value|
84+
serialized_value = if value.is_a?(Hash)
85+
value.to_json
86+
else
87+
value.to_s
88+
end
89+
90+
stream[:stream].merge!(key.to_s => serialized_value)
91+
end
92+
end
93+
94+
def time
95+
stream[:values][0] << format_time(log)
96+
end
97+
98+
def message
99+
stream[:values][0] << (log.message ? log.cleansed_message : "")
100+
end
101+
102+
def format_time(log)
103+
log.time.strftime("%s%N")
104+
end
105+
106+
def payload
107+
self.payload_value = if log.payload.respond_to?(:empty?) && !log.payload.empty?
108+
# Loki only accepts strings as key and values
109+
stringify_hash(log.payload)
110+
else
111+
{}
112+
end
113+
end
114+
115+
def metric
116+
return unless log.metric
117+
118+
payload_value[:metric] = log.metric
119+
payload_value[:metric_value] = log.metric_amount
120+
end
121+
122+
def duration
123+
return unless log.duration
124+
125+
payload_value[:duration] = log.duration.to_s
126+
payload_value[:duration_human] = log.duration_human
127+
end
128+
129+
def exception
130+
return unless log.exception
131+
132+
payload_value.merge!(
133+
exception_name: log.exception.class.name,
134+
exception_message: log.exception.message,
135+
stack_trace: log.exception.backtrace.to_s
136+
)
137+
end
138+
139+
def stringify_hash(hash)
140+
result = {}
141+
142+
hash.each do |key, value|
143+
string_key = key.to_s
144+
145+
result[string_key] = case value
146+
when Hash
147+
JSON.generate(stringify_hash(value))
148+
else
149+
value.to_s
150+
end
151+
end
152+
153+
result
154+
end
155+
end
156+
end
157+
end

0 commit comments

Comments
 (0)