Skip to content

Commit

Permalink
Added Prometheus (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
pmorelli92 authored Oct 2, 2023
1 parent 756e4cd commit 3e07514
Show file tree
Hide file tree
Showing 14 changed files with 585 additions and 162 deletions.
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ Bunnify is a library for publishing and consuming events for AMQP.

**Tracing out of the box**: Automatically injects and extracts traces when publishing and consuming. Minimal setup is required and shown on the tracer test.

**Minimal dependencies:** The intention of the library is to avoid being a vector of attack due to lots of unneeded dependencies. I will always try to triple check the dependencies and use the least quantity of libraries to achieve the functionality required.
**Only dependencies needed:** The intention of the library is to avoid having lots of unneeded dependencies. I will always try to triple check the dependencies and use the least quantity of libraries to achieve the functionality required.

- `github.com/rabbitmq/amqp091-go`: Handles the connection with AMQP protocol.
- `github.com/google/uuid`: Generates UUID for events ID and correlation ID.
- `go.uber.org/goleak`: Used on tests to verify that there are no leaks of routines on the handling of channels.
- `go.opentelemetry.io/otel`: Handles the injection and extraction of the traces on the events.
- `github.com/prometheus/client_golang`: Used in order to export metrics to Prometheus.

## Motivation

Expand All @@ -40,10 +41,6 @@ Some developers are often spoiled with these as they provide a good dev experien

Bunnify aims to provide a flexible and adaptable solution that can be used in a variety of environments and scenarios. By abstracting away many of the technical details of AMQP publishing and consumption, Bunnify makes it easy to get started with event-driven architecture without needing to be an AMQP expert.

## What is next to come

- Support for exposing Prometheus metrics.

## Examples

You can find all the working examples under the `tests` folder.
Expand Down
153 changes: 1 addition & 152 deletions bunnify/consumer.go
Original file line number Diff line number Diff line change
@@ -1,74 +1,12 @@
package bunnify

import (
"encoding/json"
"errors"
"fmt"
"time"

amqp "github.com/rabbitmq/amqp091-go"
)

type consumerOption struct {
deadLetterQueue string
exchange string
defaultHandler wrappedHandler
handlers map[string]wrappedHandler
prefetchCount int
prefetchSize int
quorumQueue bool
notificationCh chan<- Notification
}

// WithBindingToExchange specifies the exchange on which the queue
// will bind for the handlers provided.
func WithBindingToExchange(exchange string) func(*consumerOption) {
return func(opt *consumerOption) {
opt.exchange = exchange
}
}

// WithQoS specifies the prefetch count and size for the consumer.
func WithQoS(prefetchCount, prefetchSize int) func(*consumerOption) {
return func(opt *consumerOption) {
opt.prefetchCount = prefetchCount
opt.prefetchSize = prefetchSize
}
}

// WithQuorumQueue specifies that the queue to consume will be created as quorum queue.
// Quorum queues are used when data safety is the priority.
func WithQuorumQueue() func(*consumerOption) {
return func(opt *consumerOption) {
opt.quorumQueue = true
}
}

// WithDeadLetterQueue indicates which queue will receive the events
// that were NACKed for this consumer.
func WithDeadLetterQueue(queueName string) func(*consumerOption) {
return func(opt *consumerOption) {
opt.deadLetterQueue = queueName
}
}

// WithDefaultHandler specifies a handler that can be use for any type
// of routing key without a defined handler. This is mostly convenient if you
// don't care about the specific payload of the event, which will be received as a byte array.
func WithDefaultHandler(handler EventHandler[json.RawMessage]) func(*consumerOption) {
return func(opt *consumerOption) {
opt.defaultHandler = newWrappedHandler(handler)
}
}

// WithHandler specifies under which routing key the provided handler will be invoked.
// The routing key indicated here will be bound to the queue if the WithBindingToExchange is supplied.
func WithHandler[T any](routingKey string, handler EventHandler[T]) func(*consumerOption) {
return func(opt *consumerOption) {
opt.handlers[routingKey] = newWrappedHandler(handler)
}
}

// Consumer is used for consuming to events from an specified queue.
type Consumer struct {
queueName string
Expand Down Expand Up @@ -145,99 +83,10 @@ func (c Consumer) Consume() error {
return fmt.Errorf("failed to establish consuming from queue: %w", err)
}

go func() {
for delivery := range deliveries {
startTime := time.Now()
deliveryInfo := getDeliveryInfo(c.queueName, delivery)

// Establish which handler is invoked
handler, ok := c.options.handlers[deliveryInfo.RoutingKey]
if !ok {
if c.options.defaultHandler == nil {
_ = delivery.Nack(false, false)
continue
}
handler = c.options.defaultHandler
}

uevt := unmarshalEvent{DeliveryInfo: deliveryInfo}
if err := json.Unmarshal(delivery.Body, &uevt); err != nil {
_ = delivery.Nack(false, false)
continue
}

tracingCtx := extractToContext(delivery.Headers)
if err := handler(tracingCtx, uevt); err != nil {
notifyEventHandlerFailed(c.options.notificationCh, deliveryInfo.RoutingKey, err)
_ = delivery.Nack(false, false)
continue
}

elapsed := time.Since(startTime).Milliseconds()
notifyEventHandlerSucceed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed)
_ = delivery.Ack(false)
}

if !channel.IsClosed() {
channel.Close()
}

notifyChannelLost(c.options.notificationCh, NotificationSourceConsumer)

if err = c.Consume(); err != nil {
notifyChannelFailed(c.options.notificationCh, NotificationSourceConsumer, err)
}
}()

go c.loop(channel, deliveries)
return nil
}

func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo {
deliveryInfo := DeliveryInfo{
Queue: queueName,
Exchange: delivery.Exchange,
RoutingKey: delivery.RoutingKey,
}

// If routing key is empty, it is mostly due to the event being dead lettered.
// Check for the original delivery information in the headers
if delivery.RoutingKey == "" {
deaths, ok := delivery.Headers["x-death"].([]interface{})
if !ok || len(deaths) == 0 {
return deliveryInfo
}

death, ok := deaths[0].(amqp.Table)
if !ok {
return deliveryInfo
}

queue, ok := death["queue"].(string)
if !ok {
return deliveryInfo
}
deliveryInfo.Queue = queue

exchange, ok := death["exchange"].(string)
if !ok {
return deliveryInfo
}
deliveryInfo.Exchange = exchange

routingKeys, ok := death["routing-keys"].([]interface{})
if !ok || len(routingKeys) == 0 {
return deliveryInfo
}
key, ok := routingKeys[0].(string)
if !ok {
return deliveryInfo
}
deliveryInfo.RoutingKey = key
}

return deliveryInfo
}

func (c Consumer) createExchanges(channel *amqp.Channel) error {
errs := make([]error, 0)

Expand Down
62 changes: 62 additions & 0 deletions bunnify/consumerLoop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package bunnify

import (
"encoding/json"
"time"

amqp "github.com/rabbitmq/amqp091-go"
)

func (c Consumer) loop(channel *amqp.Channel, deliveries <-chan amqp.Delivery) {
for delivery := range deliveries {
startTime := time.Now()
deliveryInfo := getDeliveryInfo(c.queueName, delivery)
EventReceived(c.queueName, deliveryInfo.RoutingKey)

// Establish which handler is invoked
handler, ok := c.options.handlers[deliveryInfo.RoutingKey]
if !ok {
if c.options.defaultHandler == nil {
_ = delivery.Nack(false, false)
EventWithoutHandler(c.queueName, deliveryInfo.RoutingKey)
continue
}
handler = c.options.defaultHandler
}

uevt := unmarshalEvent{DeliveryInfo: deliveryInfo}

// For this error to happen an event not published by Bunnify is required
if err := json.Unmarshal(delivery.Body, &uevt); err != nil {
_ = delivery.Nack(false, false)
EventNotParsable(c.queueName, deliveryInfo.RoutingKey)
continue
}

tracingCtx := extractToContext(delivery.Headers)
if err := handler(tracingCtx, uevt); err != nil {
elapsed := time.Since(startTime).Milliseconds()
notifyEventHandlerFailed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed, err)
_ = delivery.Nack(false, false)
EventNack(c.queueName, deliveryInfo.RoutingKey, elapsed)
continue
}

elapsed := time.Since(startTime).Milliseconds()
notifyEventHandlerSucceed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed)
_ = delivery.Ack(false)
EventAck(c.queueName, deliveryInfo.RoutingKey, elapsed)
}

// If the for exits, the channel stopped. Close it,
// notify the error and start the consumer so it will start another loop.
if !channel.IsClosed() {
channel.Close()
}

notifyChannelLost(c.options.notificationCh, NotificationSourceConsumer)

if err := c.Consume(); err != nil {
notifyChannelFailed(c.options.notificationCh, NotificationSourceConsumer, err)
}
}
65 changes: 65 additions & 0 deletions bunnify/consumerOption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package bunnify

import (
"encoding/json"
)

type consumerOption struct {
deadLetterQueue string
exchange string
defaultHandler wrappedHandler
handlers map[string]wrappedHandler
prefetchCount int
prefetchSize int
quorumQueue bool
notificationCh chan<- Notification
}

// WithBindingToExchange specifies the exchange on which the queue
// will bind for the handlers provided.
func WithBindingToExchange(exchange string) func(*consumerOption) {
return func(opt *consumerOption) {
opt.exchange = exchange
}
}

// WithQoS specifies the prefetch count and size for the consumer.
func WithQoS(prefetchCount, prefetchSize int) func(*consumerOption) {
return func(opt *consumerOption) {
opt.prefetchCount = prefetchCount
opt.prefetchSize = prefetchSize
}
}

// WithQuorumQueue specifies that the queue to consume will be created as quorum queue.
// Quorum queues are used when data safety is the priority.
func WithQuorumQueue() func(*consumerOption) {
return func(opt *consumerOption) {
opt.quorumQueue = true
}
}

// WithDeadLetterQueue indicates which queue will receive the events
// that were NACKed for this consumer.
func WithDeadLetterQueue(queueName string) func(*consumerOption) {
return func(opt *consumerOption) {
opt.deadLetterQueue = queueName
}
}

// WithDefaultHandler specifies a handler that can be use for any type
// of routing key without a defined handler. This is mostly convenient if you
// don't care about the specific payload of the event, which will be received as a byte array.
func WithDefaultHandler(handler EventHandler[json.RawMessage]) func(*consumerOption) {
return func(opt *consumerOption) {
opt.defaultHandler = newWrappedHandler(handler)
}
}

// WithHandler specifies under which routing key the provided handler will be invoked.
// The routing key indicated here will be bound to the queue if the WithBindingToExchange is supplied.
func WithHandler[T any](routingKey string, handler EventHandler[T]) func(*consumerOption) {
return func(opt *consumerOption) {
opt.handlers[routingKey] = newWrappedHandler(handler)
}
}
51 changes: 51 additions & 0 deletions bunnify/deliveryInfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package bunnify

import (
amqp "github.com/rabbitmq/amqp091-go"
)

func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo {
deliveryInfo := DeliveryInfo{
Queue: queueName,
Exchange: delivery.Exchange,
RoutingKey: delivery.RoutingKey,
}

// If routing key is empty, it is mostly due to the event being dead lettered.
// Check for the original delivery information in the headers
if delivery.RoutingKey == "" {
deaths, ok := delivery.Headers["x-death"].([]interface{})
if !ok || len(deaths) == 0 {
return deliveryInfo
}

death, ok := deaths[0].(amqp.Table)
if !ok {
return deliveryInfo
}

queue, ok := death["queue"].(string)
if !ok {
return deliveryInfo
}
deliveryInfo.Queue = queue

exchange, ok := death["exchange"].(string)
if !ok {
return deliveryInfo
}
deliveryInfo.Exchange = exchange

routingKeys, ok := death["routing-keys"].([]interface{})
if !ok || len(routingKeys) == 0 {
return deliveryInfo
}
key, ok := routingKeys[0].(string)
if !ok {
return deliveryInfo
}
deliveryInfo.RoutingKey = key
}

return deliveryInfo
}
File renamed without changes.
Loading

0 comments on commit 3e07514

Please sign in to comment.