From cb5e72921ba547c500958c029f327803ea7f1c1b Mon Sep 17 00:00:00 2001 From: wqtt Date: Tue, 30 Jul 2024 00:40:16 -0400 Subject: [PATCH] chore(project): initial commit --- .github/workflows/test.yml | 23 ++++++ .gitignore | 3 + README.md | 82 +++++++++++++++++++- gleam.toml | 22 ++++++ manifest.toml | 19 +++++ pine.svg | 7 ++ src/pine.gleam | 150 +++++++++++++++++++++++++++++++++++++ src/pine/attribute.gleam | 6 ++ src/pine/format.gleam | 68 +++++++++++++++++ src/pine/level.gleam | 63 ++++++++++++++++ src/pine/log.gleam | 6 ++ src/pine/transport.gleam | 16 ++++ test/timber_test.gleam | 11 +++ 13 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 gleam.toml create mode 100644 manifest.toml create mode 100644 pine.svg create mode 100644 src/pine.gleam create mode 100644 src/pine/attribute.gleam create mode 100644 src/pine/format.gleam create mode 100644 src/pine/level.gleam create mode 100644 src/pine/log.gleam create mode 100644 src/pine/transport.gleam create mode 100644 test/timber_test.gleam diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1309577 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26.0.2" + gleam-version: "1.3.1" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/.gitignore b/.gitignore index 89d431a..7ae0b84 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ deps _build/ _checkouts/ +*.beam +*.ez +/build diff --git a/README.md b/README.md index be645e7..20cd5d2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,83 @@ # pine -Configurable logger for gleam. \ No newline at end of file +![](./pine.svg) + +[![Package Version](https://img.shields.io/hexpm/v/pine)](https://hex.pm/packages/timber) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/pine/) + +```sh +gleam add pine +``` + +```gleam +import pine + +pub fn main() { + // The defaults work out of the box. + // This will print a line to the console. + pine.new() + |> pine.info("hello world!") + // info hello world! + + + + // Pine is configurable! + pine.new() + + // You can set the level. + |> pine.set_level(pine.level_info) + + // As well as the format. + |> pine.set_format(pine.format_json) + + // And finally, the transport! + |> pine.set_transport(pine.transport_file("logs.txt")) + + + + // Different log functions are on the pine module. + pine.new() + + // You have access to "debug" logs + |> pine.debug("debug message") + + // As well as 'info' logs + |> pine.info("info message") + + // As well as 'warn' logs + |> pine.warn("warn message") + + // As well as 'err' logs + |> pine.err("err message") + + + // You can add attributes to loggers as needed. + // This is especially useful for telemetry and tracing. + pine.new() + + // Such as strings + |> pine.with_string("version", "v1.0.2") + + // Or ints + |> pine.with_int("page_views", 69) + + // Or floats + |> pine.with_float("opacity", 0.89) + + // or bools + |> pine.with_bool("checked_balance", False) + + // then see them all in action + |> pine.info("hello world!") + // info hello world! version=v1.0.2 page_views=69 opacity=0.89 checked_balance=false +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..3ded53b --- /dev/null +++ b/gleam.toml @@ -0,0 +1,22 @@ +name = "pine" +version = "0.0.1" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +description = "configurable logger for gleam" +licences = ["MIT"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +gleam_json = ">= 2.0.0 and < 3.0.0" +birl = ">= 1.7.1 and < 2.0.0" +simplifile = ">= 2.0.1 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..0321ae7 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,19 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_json", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB10B0E7BF44282FB25162F1A24C1A025F6B93E777CCF238C4017E4EEF2CDE97" }, + { name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" }, +] + +[requirements] +birl = { version = ">= 1.7.1 and < 2.0.0" } +gleam_json = { version = ">= 2.0.0 and < 3.0.0" } +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +simplifile = { version = ">= 2.0.1 and < 3.0.0"} diff --git a/pine.svg b/pine.svg new file mode 100644 index 0000000..8a6a0d2 --- /dev/null +++ b/pine.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/pine.gleam b/src/pine.gleam new file mode 100644 index 0000000..1c30c0a --- /dev/null +++ b/src/pine.gleam @@ -0,0 +1,150 @@ +import birl +import gleam/list +import gleam/order +import pine/attribute +import pine/format +import pine/level +import pine/log +import pine/transport + +/// The logger type. This is opaque by default to prevent developers from relying +/// on implementation details that may change. +pub opaque type Logger { + Logger( + level: level.Level, + format: format.Formatter, + transport: transport.Transport, + attributes: List(attribute.Attribute), + ) +} + +/// Create a new Logger. +/// +/// This uses the defaults of: +/// - plain format +/// - console transport +/// - debug level +pub fn new() -> Logger { + Logger(level.debug, format.plain, transport.console, []) +} + +/// Creates a formatter that outputs to `json`. +pub fn format_json() -> format.Formatter { + format.json +} + +/// Creates a formatter that outputs to one line of plain text where each attribute is separated by a space. +pub fn format_plain() -> format.Formatter { + format.plain +} + +/// Creates a transport that writes the given file. +pub fn transport_file(filename) -> transport.Transport { + transport.file(filename) +} + +/// Creates a transport that writes to the console. +pub fn transport_console() -> transport.Transport { + transport.console +} + +/// The `debug` logging level. +pub fn level_debug() { + level.debug +} + +/// The `info` logging level. +pub fn level_info() { + level.info +} + +/// The `warn` logging level. +pub fn level_warn() { + level.warn +} + +/// The `err` logging level. +pub fn level_err() { + level.err +} + +/// Sets the logger to the given level. +pub fn set_level(logger: Logger, new_level: level.Level) -> Logger { + Logger(new_level, logger.format, logger.transport, logger.attributes) +} + +/// Sets the logger to the given format. +pub fn set_format(logger: Logger, new_format: format.Formatter) -> Logger { + Logger(logger.level, new_format, logger.transport, logger.attributes) +} + +/// Sets the logger to the given transport. +pub fn set_transport( + logger: Logger, + new_transport: transport.Transport, +) -> Logger { + Logger(logger.level, logger.format, new_transport, logger.attributes) +} + +fn add_attribute(logger: Logger, attribute: attribute.Attribute) -> Logger { + Logger( + logger.level, + logger.format, + logger.transport, + list.append(logger.attributes, [attribute]), + ) +} + +/// Creates a debug log. +pub fn debug(logger: Logger, msg: String) -> Logger { + send_log(logger, level.debug, msg) +} + +/// Creates an info log. +pub fn info(logger: Logger, msg: String) -> Logger { + send_log(logger, level.info, msg) +} + +/// Creates a warn log. +pub fn warn(logger: Logger, msg: String) -> Logger { + send_log(logger, level.warn, msg) +} + +/// Creates an err log. +pub fn err(logger: Logger, msg: String) -> Logger { + send_log(logger, level.err, msg) +} + +fn send_log(logger: Logger, lev: level.Level, msg: String) -> Logger { + case level.compare(logger.level, lev) { + order.Lt -> logger + _ -> { + let now = birl.to_unix_milli(birl.now()) + + log.Log(ts: now, level: lev, msg: msg, attributes: logger.attributes) + |> logger.format + |> logger.transport + |> fn(_) { logger } + } + } +} + +/// Adds a new 'string' attribute to a logger. +pub fn with_string(logger: Logger, key: String, value: String) -> Logger { + add_attribute(logger, attribute.StringAttribute(key, value)) +} + +/// Adds a new 'int' attribute to a logger. +pub fn with_int(logger: Logger, key: String, value: Int) -> Logger { + add_attribute(logger, attribute.IntAttribute(key, value)) +} + +/// Adds a new 'float' attribute to a logger. +pub fn with_float(logger: Logger, key: String, value: Float) -> Logger { + add_attribute(logger, attribute.FloatAttribute(key, value)) +} + +/// Adds a new 'bool' attribute to a logger. +pub fn with_bool(logger: Logger, key: String, value: Bool) -> Logger { + add_attribute(logger, attribute.BoolAttribute(key, value)) +} diff --git a/src/pine/attribute.gleam b/src/pine/attribute.gleam new file mode 100644 index 0000000..b28ae23 --- /dev/null +++ b/src/pine/attribute.gleam @@ -0,0 +1,6 @@ +pub type Attribute { + StringAttribute(String, String) + IntAttribute(String, Int) + FloatAttribute(String, Float) + BoolAttribute(String, Bool) +} diff --git a/src/pine/format.gleam b/src/pine/format.gleam new file mode 100644 index 0000000..9a21bdf --- /dev/null +++ b/src/pine/format.gleam @@ -0,0 +1,68 @@ +import gleam/bool +import gleam/float +import gleam/int +import gleam/json as gleam_json +import gleam/list +import gleam/string +import pine/attribute +import pine/level +import pine/log + +pub type Formatter = + fn(log.Log) -> String + +pub fn plain(log: log.Log) -> String { + let extended_attributes = + list.fold(log.attributes, [], fn(acc, next) { + let attr = case next { + attribute.StringAttribute(key, value) -> key <> "=" <> value + + attribute.IntAttribute(key, value) -> key <> "=" <> int.to_string(value) + + attribute.FloatAttribute(key, value) -> + key <> "=" <> float.to_string(value) + + attribute.BoolAttribute(key, value) -> + key <> "=" <> bool.to_string(value) + } + + list.append(acc, [attr]) + }) + + string.join( + [ + level.to_string(log.level), + int.to_string(log.ts), + log.msg, + ..extended_attributes + ], + " ", + ) +} + +pub fn json(log: log.Log) -> String { + let default_attributes = [ + #("ts", gleam_json.int(log.ts)), + #("level", gleam_json.string(level.to_string(log.level))), + #("msg", gleam_json.string(log.msg)), + ] + + let extended_attributes = + list.fold(log.attributes, [], fn(acc, next) { + let tup = case next { + attribute.StringAttribute(key, value) -> #( + key, + gleam_json.string(value), + ) + attribute.IntAttribute(key, value) -> #(key, gleam_json.int(value)) + attribute.FloatAttribute(key, value) -> #(key, gleam_json.float(value)) + attribute.BoolAttribute(key, value) -> #(key, gleam_json.bool(value)) + } + + list.append(acc, [tup]) + }) + + let properties = list.append(default_attributes, extended_attributes) + + gleam_json.to_string(gleam_json.object(properties)) +} diff --git a/src/pine/level.gleam b/src/pine/level.gleam new file mode 100644 index 0000000..d00514f --- /dev/null +++ b/src/pine/level.gleam @@ -0,0 +1,63 @@ +import gleam/int +import gleam/order.{type Order} + +/// Levels allow developers to categorize logs based on the type of information +/// the log contains. Levels can determine which logs are actually processed. +/// If the logger is configured with a _lower_ logging level than a particular +/// log uses, then that log will not be processed. +/// +/// Levels are sequential in that each level contains the ones below it. +/// Eg, a logger configured with the `Debug` level will log every message because +/// `Debug` is the highest level. In contrast, a logger configured with the `Warn` +/// level will not display `Info` or `Debug` logs. +pub opaque type Level { + Debug + Info + Warn + Err +} + +pub const debug = Debug + +pub const info = Info + +pub const warn = Warn + +pub const err = Err + +/// Compare two levels. +pub fn compare(left: Level, right: Level) -> Order { + int.compare(to_int(left), to_int(right)) +} + +/// Transform an `Int` into it's corresonding log `Level`. Returns Ok if the int +/// is a valid log leve, otherwise Error(Nil). +pub fn from_int(level: Int) -> Result(Level, Nil) { + case level { + 3 -> Ok(Debug) + 2 -> Ok(Info) + 1 -> Ok(Warn) + 0 -> Ok(Err) + _ -> Error(Nil) + } +} + +/// Transform a log `Level` into it's corresonding int value. +pub fn to_int(level: Level) -> Int { + case level { + Debug -> 3 + Info -> 2 + Warn -> 1 + Err -> 0 + } +} + +/// Transform a log `Level` into it's corresonding int value. +pub fn to_string(level: Level) -> String { + case level { + Debug -> "debug" + Info -> "info" + Warn -> "warn" + Err -> "err" + } +} diff --git a/src/pine/log.gleam b/src/pine/log.gleam new file mode 100644 index 0000000..e304d10 --- /dev/null +++ b/src/pine/log.gleam @@ -0,0 +1,6 @@ +import pine/attribute.{type Attribute} +import pine/level.{type Level} + +pub type Log { + Log(level: Level, ts: Int, msg: String, attributes: List(Attribute)) +} diff --git a/src/pine/transport.gleam b/src/pine/transport.gleam new file mode 100644 index 0000000..5ebbad8 --- /dev/null +++ b/src/pine/transport.gleam @@ -0,0 +1,16 @@ +import gleam/io +import simplifile + +pub type Transport = + fn(String) -> Nil + +pub fn console(payload) { + io.println(payload) +} + +pub fn file(filename: String) { + fn(payload) { + let _ = simplifile.append(filename, payload) + Nil + } +} diff --git a/test/timber_test.gleam b/test/timber_test.gleam new file mode 100644 index 0000000..bf7d6ac --- /dev/null +++ b/test/timber_test.gleam @@ -0,0 +1,11 @@ +import gleeunit +import gleeunit/should + +pub fn main() { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + should.equal(1, 1) +}