1
0
mirror of https://github.com/meineerde/rackstash.git synced 2026-02-20 18:31:59 +00:00

277 lines
9.7 KiB
Ruby

# frozen_string_literal: true
#
# Copyright 2017 - 2018 Holger Just
#
# This software may be modified and distributed under the terms
# of the MIT license. See the LICENSE.txt file for details.
require 'fileutils'
require 'pathname'
require 'thread'
require 'rackstash/adapter/base_adapter'
module Rackstash
module Adapter
# This log adapter allows to write logs to a file accessible on the local
# filesystem. Written log lines are delimited by a newline character (`\n`).
# A suitable encoders should ensure that single logs do not contain any
# verbatim newline characters themselves. All Rackstash encoders producing
# JSON formatted logs are suitable in this regard.
#
# When writing the logs, we assume filesystem semantics of the usual local
# filesystems used on Linux, macOS, BSDs, or Windows. Here, we can ensure
# that even concurrent writes of multiple processes (e.g. multiple worker
# processes of an application server) don't produce interleaved log lines.
#
# When using a remote filesystem it might be possible that concurrent log
# writes to the same file are interleaved on disk, resulting on probable
# log corruption. If this is a concern, you should make sure that only one
# log adapter of one process write to a log file at a time or (preferrably)
# write to a local file instead. This restriction applies to NFS and most
# FUSE filesystems like sshfs. SMB is likely safe to use here.
#
# When reading the log file, the reader might still see incomplete writes
# depending on the OS and filesystem. Since we are only writing complete
# lines, it should be safe to continue reading until you observe a newline
# (`\n`) character.
#
# Assuming you are creating the log adapter like this
#
# Rackstash::Adapter::File.new('/var/log/rackstash/my_app.log')
#
# you can rotate the file with a config for the standard
# [logrotate](https://github.com/logrotate/logrotate) utility similar to
# this example:
#
# /var/log/rackstash/my_app.log {
# daily
# rotate 30
#
# # file might be missing if there were no writes that day
# missingok
# notifempty
#
# # compress old logfiles but keep the newest rotate file uncompressed
# # to still allow writes during rotation
# compress
# delaycompress
# }
#
# Since the {File} adapter automatically reopens the logfile after the
# file was moved, you don't need to create the new file there nor should you
# use the (potentially destructive) `copytruncate` option of logrotate.
class File < BaseAdapter
register_for ::String, ::Pathname, 'file'
# @return [String] the absolute path to the currently opened log file
attr_reader :path
# @return [String] the absolute path to the originally defined log file.
# Depending on the {#rotate} setting, the final log file might have a
# date-based suffix added before its file extension. Use {#path} to
# get the full path of the currently opened log file.
attr_reader :base_path
# @return [String, Proc, nil] date pattern for the file suffix used for
# auto-rotated log files. The pattern is used with `Date#strftime` to
# determine the file suffix for the current rotate file. When setting a
# `Proc`, it is expected to return the currently final log file suffix
# (not just a date pattern). When setting the value to `nil`, the log
# file is not rotated.
attr_reader :rotate
def self.from_uri(uri)
uri = URI(uri)
if (uri.scheme || uri.opaque) == 'file'.freeze
file_options = parse_uri_options(uri)
if file_options[:auto_reopen] =~ /\A(:?false|0)?\z/i
file_options[:auto_reopen] = false
end
new(uri.path, **file_options)
else
raise ArgumentError, "Invalid URI: #{uri}"
end
end
# Create a new file adapter instance which writes logs to the log file
# specified in `path`.
#
# We will always resolve the `path` to an absolute path once during
# initialization. When passing a relative path, it will be resolved
# according to the current working directory. See
# [`::File.expand_path`](https://ruby-doc.org/core/File.html#method-c-expand_path)
# for details.
#
# @param path [String, Pathname] the path to the logfile. Depending on the
# `rotate` setting, the final log file might have a date-based suffix
# added before its file extension.
# @param auto_reopen (see #auto_reopen=)
# @param rotate (see #rotate=)
def initialize(path, auto_reopen: true, rotate: nil)
@base_path = ::File.expand_path(path).freeze
self.auto_reopen = auto_reopen
self.rotate = rotate
@mutex = Mutex.new
open_file(rotated_path)
end
# @return [Boolean] if `true`, the logfile will be automatically reopened
# on write if it is (re-)moved on the filesystem
def auto_reopen?
@auto_reopen
end
# @param auto_reopen [Boolean] set to `true` to automatically reopen the
# log file (and potentially create a new one) if we detect that the
# current log file was moved or deleted, e.g. due to an external
# logrotate run
def auto_reopen=(auto_reopen)
@auto_reopen = !!auto_reopen
end
# @param rotate [String, Proc, nil] date pattern for the file suffix used
# for auto-rotated log files. When giving a `String` here, it is
# interpreted as a pattern for the `Date#strftime` method. In addition
# to that, we accept the following names: `"daily"`, `"weekly"`, and
# `"monthly"` for pre-defined suffixes. When giving a `Proc`, it is
# expected to return the final suffix on call (i.e. not just a
# `Date#strftime` pattern but the actual file suffix). When defining a
# rotate pattern, each log event is written to a file with the resulting
# suffix added before its file extension.
def rotate=(rotate)
@rotate = case rotate
when :daily, 'daily'.freeze
'%Y-%m-%d'.freeze
when :weekly, 'weekly'.freeze
'%G-w%V'.freeze
when :monthly, 'monthly'.freeze
'%Y-%m'.freeze
when String
rotate.dup.freeze
when Proc, nil
rotate
else
raise ArgumentError, "Invalid rotate specification: #{rotate.inspect}"
end
end
# Write a single log line with a trailing newline character to the open
# file. If {#auto_reopen?} is `true`, we will reopen the file object
# before the write if we detect that the file was moved, e.g., from an
# external logrotate run.
#
# When writing the log line, ruby uses a single `fwrite(2)` syscall with
# `IO#write`. Since we are using unbuffered (sync) IO, concurrent writes
# to the file from multiple processes
# [are guaranteed](https://stackoverflow.com/a/35256561/421705) to be
# serialized by the kernel without overlap.
#
# @param log [#to_s] the encoded log event
# @return [nil]
def write_single(log)
line = normalize_line(log)
return if line.empty?
@mutex.synchronize do
rotate_file
@file.syswrite(line)
end
nil
end
# Close the file. After closing, no further writes are possible. Further
# attempts to {#write} will result in an exception being thrown.
#
# We will not automatically reopen a closed file on {#write}. You have to
# explicitly call {#reopen} in this case.
#
# @return [nil]
def close
@mutex.synchronize do
@file.close
end
nil
end
# Reopen the logfile. We will open the file located at the original
# {#path} or create a new one if it does not exist.
#
# If the file can not be opened, an exception will be raised.
# @return [nil]
def reopen
@mutex.synchronize do
reopen_file rotated_path
end
nil
end
private
# Reopen the log file if the original {#path} does not reference the
# opened file anymore (e.g. because it was moved or deleted)
def auto_reopen!
return unless @auto_reopen
return unless @file && @path
return if @file.closed?
return if ::File.identical?(@file, @path)
reopen_file(@path)
end
def open_file(path)
dirname = ::File.dirname(path)
FileUtils.mkdir_p(dirname) unless ::File.exist?(dirname)
mode = ::File::WRONLY | ::File::APPEND | ::File::CREAT
# Allow external processes to delete the log file on Windows.
# This is available since Ruby 2.3.0.
mode |= ::File::SHARE_DELETE if defined?(::File::SHARE_DELETE)
file = ::File.new(path, mode: mode, binmode: true)
file.sync = true
@path = path
@file = file
nil
end
def reopen_file(path)
@file.close rescue nil
open_file(path)
end
def rotate_file
path = rotated_path
if path == @path
auto_reopen!
else
reopen_file(path)
end
end
def rotated_path
suffix = case @rotate
when String
Date.today.strftime(@rotate)
when Proc
@rotate.call.to_s
else
EMPTY_STRING
end
return @base_path if suffix.empty?
suffix = ".#{suffix}"
@base_path.sub(/\A(.*?)(\.[^.\/]+)?\z/) { "#{$1}#{suffix}#{$2}" }
end
end
end
end