Skip to content

Instantly share code, notes, and snippets.

@ahoward
Last active July 11, 2025 19:33
Show Gist options
  • Save ahoward/d25e2a39dbd2cd1c12dcb4510cea9531 to your computer and use it in GitHub Desktop.
Save ahoward/d25e2a39dbd2cd1c12dcb4510cea9531 to your computer and use it in GitHub Desktop.

Revisions

  1. ahoward revised this gist Jul 11, 2025. 3 changed files with 0 additions and 0 deletions.
    File renamed without changes.
    File renamed without changes.
    File renamed without changes.
  2. ahoward renamed this gist Jul 11, 2025. 1 changed file with 0 additions and 0 deletions.
    File renamed without changes.
  3. ahoward revised this gist Jul 11, 2025. 3 changed files with 142 additions and 0 deletions.
    File renamed without changes.
    5 changes: 5 additions & 0 deletions initializer.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,5 @@
    # file: config/initializers/s3.rb

    require Rails.root.join('lib/s3.rb')

    S3.config = Rails.application.credentials.fetch(:aws)
    137 changes: 137 additions & 0 deletions lib.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,137 @@
    # file: lib/s3.rb

    require 'aws-sdk-s3'
    require 'mime/types'

    module S3
    def config
    @config ||= Hash.new
    end

    def config=(config)
    @config = config
    end

    def config_for(key)
    config.fetch(key.to_s) { config.fetch(key.to_sym) }
    end

    def region
    config_for(:region)
    end

    def access_key_id
    config_for(:access_key_id)
    end

    def secret_access_key
    config_for(:secret_access_key)
    end

    def bucket
    config_for(:bucket)
    end

    def client
    Aws::S3::Client.new(
    region:,
    access_key_id:,
    secret_access_key:,
    )
    end

    def url_for(key)
    "https://#{bucket}.s3.amazonaws.com/#{key}"
    end

    def write(key, body, **kws)
    kws[:content_type] ||= MIME::Types.type_for(key).first.to_s
    kws[:content_disposition] ||= 'inline'

    args = {
    bucket:,
    key:,
    body:,
    **kws
    }

    obj = client.put_object(**args)

    url = url_for(key)

    { key:, url: }.update(args)
    end
    alias_method :put, :write

    def read(key, &block)
    obj = client.get_object(bucket:, key: key)

    if block
    while (chunk = obj.body.read(1024))
    block.call(chunk)
    end
    else
    obj.body.string
    end
    end
    alias_method :get, :read

    def list(prefix = '', limit: nil, &block)
    args = { bucket:, prefix: }

    accum = []
    n = 0

    client.list_objects_v2(**args).each do |response|
    response.contents.each do |obj|
    key = obj.key
    block ? block.call(key) : accum.push(key)
    n += 1
    break if limit && (n >= limit)
    end

    break if limit && (n >= limit)
    end

    block ? nil : accum
    end
    alias_method :ls, :list

    def delete(key, *keys)
    keys.unshift(key)

    keys.flatten!
    keys.compact!

    to_delete = keys.map { { key: } }

    to_delete.each_slice(1000) do |objects|
    args = { bucket:, delete: { objects: } }
    client.delete_objects(args)
    end

    keys
    end
    alias_method :rm, :delete

    def exist(key)
    begin
    client.head_object(bucket:, key: key)
    true
    rescue Aws::S3::Errors::NotFound => e
    false
    end
    end
    alias_method :exist?, :exist
    alias_method :exists?, :exist

    def fetch(key, &block)
    begin
    read(key)
    rescue Aws::S3::Errors::NoSuchKey => _error
    block.call.tap { |obj| write(key, obj) }
    end
    end

    extend self
    end
  4. ahoward created this gist Jul 11, 2025.
    76 changes: 76 additions & 0 deletions s3_errors.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,76 @@
    # file: app/controllers/concerns/s3_errors.rb

    =begin
    TL;DR;
    class ApplicationController
    include S3Errors
    end
    =end

    module S3Errors
    extend ActiveSupport::Concern

    included do
    rescue_from StandardError, with: :upload_error_to_s3
    end

    def S3Errors.rotate!(cutoff: nil)
    cutoff ||= 1.week.ago.beginning_of_day
    re = %r{errors/(\d{4})/(\d{2})/(\d{2})/}
    n = 0

    S3.list('errors/') do |key|
    if((match = key.match(re)))
    yyyy, mm, dd = match[1].to_i, match[2].to_i, match[3].to_i

    date = Date.new(yyyy, mm, dd)

    if date < cutoff.to_date
    S3.delete(key)
    n += 1
    end
    end
    end

    return n
    end

    AutoRotater = Thread.new do
    loop do
    begin
    sleep 1.minute

    S3Errors.rotate!
    rescue => error
    Rails.logger.error(error)
    end

    sleep 59.minutes
    end
    end

    private

    def upload_error_to_s3(error)
    uuid = SecureRandom.uuid_v7.to_s

    now = Time.now.utc
    path = "errors/#{now.strftime('%Y/%m/%d')}/#{uuid}.json"

    data = {
    class: error.class.name,
    message: error.message,
    backtrace: error.backtrace,
    created_at: now.iso8601(2)
    }

    json = data.to_json

    S3.write(path, json)

    raise error
    end
    end