From 8bf721f672eaf7957580531104d0a0f72215868a Mon Sep 17 00:00:00 2001 From: cyberbit Date: Mon, 10 Jun 2024 01:00:24 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=93=A6=20initial=20work=20for=20middl?= =?UTF-8?q?eware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 2 +- src/telem/init.lua | 5 +- src/telem/lib/Backplane.lua | 62 +- src/telem/lib/BaseMiddleware.lua | 27 + src/telem/lib/InputAdapter.lua | 24 +- src/telem/lib/middleware.lua | 6 + .../lib/middleware/CalcAverageMiddleware.lua | 59 ++ .../lib/middleware/CalcDeltaMiddleware.lua | 83 +++ .../middleware/HandleCollectionMiddleware.lua | 23 + .../middleware/SortCollectionMiddleware.lua | 23 + src/telem/vendor/fluent.lua | 679 ++++++++++++++++++ src/telem/vendor/init.lua | 4 +- 12 files changed, 979 insertions(+), 18 deletions(-) create mode 100644 src/telem/lib/BaseMiddleware.lua create mode 100644 src/telem/lib/middleware.lua create mode 100644 src/telem/lib/middleware/CalcAverageMiddleware.lua create mode 100644 src/telem/lib/middleware/CalcDeltaMiddleware.lua create mode 100644 src/telem/lib/middleware/HandleCollectionMiddleware.lua create mode 100644 src/telem/lib/middleware/SortCollectionMiddleware.lua create mode 100644 src/telem/vendor/fluent.lua diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 83764e5..08af515 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -10,7 +10,7 @@ "presentation": { "close": true, "focus": false, - "reveal": "always", + "reveal": "silent", "panel": "shared" } } diff --git a/src/telem/init.lua b/src/telem/init.lua index dd1da6b..20b65ea 100644 --- a/src/telem/init.lua +++ b/src/telem/init.lua @@ -1,12 +1,13 @@ -- Telem by cyberbit -- MIT License --- Version 0.6.0 +-- Version 0.7.0 local _Telem = { - _VERSION = '0.6.0', + _VERSION = '0.7.0', util = require 'telem.lib.util', input = require 'telem.lib.input', output = require 'telem.lib.output', + middleware = require 'telem.lib.middleware', -- API backplane = require 'telem.lib.Backplane', diff --git a/src/telem/lib/Backplane.lua b/src/telem/lib/Backplane.lua index 272c45f..722a4a0 100644 --- a/src/telem/lib/Backplane.lua +++ b/src/telem/lib/Backplane.lua @@ -4,6 +4,7 @@ local t = require 'telem.lib.util' local InputAdapter = require 'telem.lib.InputAdapter' local OutputAdapter = require 'telem.lib.OutputAdapter' local MetricCollection = require 'telem.lib.MetricCollection' +local Middleware = require 'telem.lib.BaseMiddleware' local Backplane = o.class() Backplane.type = 'Backplane' @@ -18,6 +19,7 @@ function Backplane:constructor () self.inputs = {} self.outputs = {} + self.middlewares = {} -- workaround to guarantee processing order self.inputKeys = {} @@ -81,14 +83,45 @@ function Backplane:addOutput (name, output) return self end +function Backplane:middleware (...) + local args = {...} + + for _, middleware in ipairs(args) do + self:addMiddleware(middleware) + end + + return self +end + +function Backplane:addMiddleware (middleware) + assert(o.instanceof(middleware, Middleware), 'middleware must be a Middleware') + + table.insert(self.middlewares, middleware) + + return self +end + function Backplane:addAsyncCycleHandler (adapter, handler) table.insert(self.asyncCycleHandlers, handler) end --- NYI -function Backplane:processMiddleware () - -- - return self +function Backplane:processMiddleware (middlewares, collection) + assert(middlewares and type(middlewares) == 'table', 'middlewares must be a list of Middleware') + + local newCollection = collection + + for _, middleware in ipairs(middlewares) do + local results = {pcall(middleware.handle, middleware, newCollection)} + + if not table.remove(results, 1) then + t.log('Middleware fault:') + t.pprint(table.remove(results, 1)) + end + + newCollection = table.remove(results, 1) + end + + return newCollection end function Backplane:cycle() @@ -139,10 +172,13 @@ function Backplane:cycle() t.log('Input fault for "' .. key .. '":') t.pprint(table.remove(results, 1)) else - local inputMetrics = table.remove(results, 1) + local inputCollection = table.remove(results, 1) + + -- process input middleware + local processedInputCollection = self:processMiddleware(input.middlewares, inputCollection) - -- attach adapter name - for _,v in ipairs(inputMetrics.metrics) do + for _,v in ipairs(processedInputCollection.metrics) do + -- attach adapter name v.adapter = key .. (v.adapter and ':' .. v.adapter or '') table.insert(tempMetrics, v) @@ -150,13 +186,8 @@ function Backplane:cycle() end end - -- TODO process middleware - - self:dlog('Backplane:cycle :: sorting metrics...') + self:dlog('Backplane:cycle :: committing metrics...') - -- sort - -- TODO make this a middleware - table.sort(tempMetrics, function (a,b) return a.name < b.name end) for _,v in ipairs(tempMetrics) do metrics:insert(v) end @@ -164,6 +195,7 @@ function Backplane:cycle() self:dlog('Backplane:cycle :: saving state...') self:dlog('Backplane:cycle :: - Backplane') + self.collection = metrics -- cache output states @@ -199,6 +231,10 @@ function Backplane:cycle() end end + self:dlog('Backplane:cycle :: processing middleware...') + + self.collection = self:processMiddleware(self.middlewares, self.collection) + self:dlog('Backplane:cycle :: writing outputs...') -- write outputs diff --git a/src/telem/lib/BaseMiddleware.lua b/src/telem/lib/BaseMiddleware.lua new file mode 100644 index 0000000..c2fbf37 --- /dev/null +++ b/src/telem/lib/BaseMiddleware.lua @@ -0,0 +1,27 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' + +local Middleware = o.class() +Middleware.type = 'Middleware' + +function Middleware:constructor() + assert(self.type ~= Middleware.type, 'Middleware cannot be instantiated') + + self.debugState = false +end + +function Middleware:handle(backplane) + error(self.type .. ' has not implemented handle()') +end + +function Middleware:debug(debug) + self.debugState = debug and true or false + + return self +end + +function Middleware:dlog(msg) + if self.debugState then t.log(msg) end +end + +return Middleware \ No newline at end of file diff --git a/src/telem/lib/InputAdapter.lua b/src/telem/lib/InputAdapter.lua index 5ecaa0d..3e3062d 100644 --- a/src/telem/lib/InputAdapter.lua +++ b/src/telem/lib/InputAdapter.lua @@ -4,6 +4,8 @@ local t = require 'telem.lib.util' local InputAdapter = o.class() InputAdapter.type = 'InputAdapter' +local Middleware = require 'telem.lib.BaseMiddleware' + function InputAdapter:constructor() assert(self.type ~= InputAdapter.type, 'InputAdapter cannot be instantiated') @@ -11,7 +13,9 @@ function InputAdapter:constructor() self.prefix = '' self.asyncCycleHandler = nil - + + self.middlewares = {} + -- boot components self:setBoot(function () self.components = {} @@ -58,6 +62,24 @@ function InputAdapter:read () t.err(self.type .. ' has not implemented read()') end +function InputAdapter:middleware (...) + local args = {...} + + for _, middleware in ipairs(args) do + self:addMiddleware(middleware) + end + + return self +end + +function InputAdapter:addMiddleware (middleware) + assert(o.instanceof(middleware, Middleware), 'middleware must be a Middleware') + + table.insert(self.middlewares, middleware) + + return self +end + function InputAdapter:debug(debug) self.debugState = debug and true or false diff --git a/src/telem/lib/middleware.lua b/src/telem/lib/middleware.lua new file mode 100644 index 0000000..78df4c2 --- /dev/null +++ b/src/telem/lib/middleware.lua @@ -0,0 +1,6 @@ +return { + handleCollection = require 'telem.lib.middleware.HandleCollectionMiddleware', + calcAvg = require 'telem.lib.middleware.CalcAverageMiddleware', + calcDelta = require 'telem.lib.middleware.CalcDeltaMiddleware', + sort = require 'telem.lib.middleware.SortCollectionMiddleware', +} \ No newline at end of file diff --git a/src/telem/lib/middleware/CalcAverageMiddleware.lua b/src/telem/lib/middleware/CalcAverageMiddleware.lua new file mode 100644 index 0000000..74e6b57 --- /dev/null +++ b/src/telem/lib/middleware/CalcAverageMiddleware.lua @@ -0,0 +1,59 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' + +local fluent = require('telem.vendor').fluent + +local Metric = require 'telem.lib.Metric' +local Middleware = require 'telem.lib.BaseMiddleware' + +local CalcAverageMiddleware = o.class(Middleware) +CalcAverageMiddleware.type = 'CalcAverageMiddleware' + +function CalcAverageMiddleware:constructor(windowSize) + self:super('constructor') + + self.windowSize = windowSize or 50 + self.forceProcess = false + + self.history = {} +end + +function CalcAverageMiddleware:force() + self.forceProcess = true + + return self +end + +function CalcAverageMiddleware:handle(target) + assert(target.type == 'MetricCollection', 'CalcAverageMiddleware:handle :: target must be a MetricCollection') + + return self:handleCollection(target) +end + +function CalcAverageMiddleware:handleCollection(collection) + for _, v in ipairs(collection.metrics) do + if self.forceProcess or v.source ~= 'middleware' then + self.history[v.name] = self.history[v.name] or {} + + t.constrainAppend(self.history[v.name], v.value, self.windowSize) + end + end + + for k, v in pairs(self.history) do + local sum + + for _, hv in ipairs(v) do + sum = sum and sum + hv or hv + end + + collection:insert(Metric{ + name = k .. '_avg', + value = sum and sum / #v or 0, + source = 'middleware' + }) + end + + return collection +end + +return CalcAverageMiddleware \ No newline at end of file diff --git a/src/telem/lib/middleware/CalcDeltaMiddleware.lua b/src/telem/lib/middleware/CalcDeltaMiddleware.lua new file mode 100644 index 0000000..9e48e11 --- /dev/null +++ b/src/telem/lib/middleware/CalcDeltaMiddleware.lua @@ -0,0 +1,83 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' + +local fluent = require('telem.vendor').fluent + +local Metric = require 'telem.lib.Metric' +local Middleware = require 'telem.lib.BaseMiddleware' + +local CalcDeltaMiddleware = o.class(Middleware) +CalcDeltaMiddleware.type = 'CalcDeltaMiddleware' + +function CalcDeltaMiddleware:constructor(windowSize) + self:super('constructor') + + self.windowSize = windowSize or 50 + self.rateInterval = 1 + self.forceProcess = false + + self.history = {} + self.times = {} +end + +function CalcDeltaMiddleware:force() + self.forceProcess = true + + return self +end + +function CalcDeltaMiddleware:interval(interval) + local factor, unit = interval:match('^(%d+)(%l)$') + + assert(factor, 'CalcDeltaMiddleware:interval :: invalid interval format') + + self.rateInterval = tonumber(factor) * fluent(unit):toLookup({ s = 1, m = 60, h = 3600, d = 86400 }):result() + + return self +end + +function CalcDeltaMiddleware:handle(target) + assert(target.type == 'MetricCollection', 'CalcDeltaMiddleware:handle :: target must be a MetricCollection') + + return self:handleCollection(target) +end + +function CalcDeltaMiddleware:handleCollection(collection) + local timestamp = os.epoch('utc') / 1000 + + for _, v in ipairs(collection.metrics) do + if self.forceProcess or v.source ~= 'middleware' then + self.history[v.name] = self.history[v.name] or {} + self.times[v.name] = self.times[v.name] or {} + + t.constrainAppend(self.history[v.name], v.value, self.windowSize) + t.constrainAppend(self.times[v.name], timestamp, self.windowSize) + end + end + + for k, v in pairs(self.history) do + local idelta, delta, irate, rate = 0, 0, 0, 0 + + if #v >= 2 then + local vt = self.times[k] + + idelta = v[#v] - v[#v - 1] + delta = v[#v] - v[1] + + local itimedelta = vt[#vt] - vt[#vt - 1] + local timedelta = vt[#vt] - vt[1] + + irate = (idelta / itimedelta) * self.rateInterval + rate = (delta / timedelta) * self.rateInterval + end + + collection:insert(Metric{ name = k .. '_idelta', value = idelta, source = 'middleware' }) + collection:insert(Metric{ name = k .. '_delta', value = delta, source = 'middleware' }) + collection:insert(Metric{ name = k .. '_irate', value = irate, source = 'middleware' }) + collection:insert(Metric{ name = k .. '_rate', value = rate, source = 'middleware' }) + end + + return collection +end + +return CalcDeltaMiddleware \ No newline at end of file diff --git a/src/telem/lib/middleware/HandleCollectionMiddleware.lua b/src/telem/lib/middleware/HandleCollectionMiddleware.lua new file mode 100644 index 0000000..4ab5ff1 --- /dev/null +++ b/src/telem/lib/middleware/HandleCollectionMiddleware.lua @@ -0,0 +1,23 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' + +local Middleware = require 'telem.lib.BaseMiddleware' + +local HandleCollection = o.class(Middleware) +HandleCollection.type = 'HandleCollection' + +function HandleCollection:constructor(handler) + self:super('constructor') + + self.handler = handler +end + +function HandleCollection:handle(collection) + assert(collection.type == 'MetricCollection', 'HandleCollection:handle :: collection must be a MetricCollection') + + local newCollection = assert(self.handler(collection), 'HandleCollection:handle :: handler must return a MetricCollection') + + return newCollection +end + +return HandleCollection \ No newline at end of file diff --git a/src/telem/lib/middleware/SortCollectionMiddleware.lua b/src/telem/lib/middleware/SortCollectionMiddleware.lua new file mode 100644 index 0000000..986b251 --- /dev/null +++ b/src/telem/lib/middleware/SortCollectionMiddleware.lua @@ -0,0 +1,23 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' +local fluent = require 'telem.vendor'.fluent + +local Middleware = require 'telem.lib.BaseMiddleware' +local MetricCollection = require 'telem.lib.MetricCollection' + +local SortCollectionMiddleware = o.class(Middleware) +SortCollectionMiddleware.type = 'SortCollectionMiddleware' + +function SortCollectionMiddleware:constructor() + self:super('constructor') +end + +function SortCollectionMiddleware:handle(collection) + assert(collection.type == 'MetricCollection', 'SortCollectionMiddleware:handle :: collection must be a MetricCollection') + + fluent(collection.metrics):sortBy('name') + + return collection +end + +return SortCollectionMiddleware \ No newline at end of file diff --git a/src/telem/vendor/fluent.lua b/src/telem/vendor/fluent.lua new file mode 100644 index 0000000..4915858 --- /dev/null +++ b/src/telem/vendor/fluent.lua @@ -0,0 +1,679 @@ +-- Fluent by cyberbit +-- MIT License +-- Version 0.2.0 + +---@class cyberbit.Fluent +---@field value any +---@field queue function[] +---@field params { [string]: any } +---@overload fun(value?: any): cyberbit.Fluent +local Fluent = setmetatable({}, { + ---@param class cyberbit.Fluent + ---@param ... any + ---@return cyberbit.Fluent + __call = function (class, ...) + local obj = {} + + setmetatable(obj, { __index = class }) + + class.constructor(obj, ...) + + return obj + end +}) + +--- Constructs a new instance of the Fluent class. +--- +--- If a value is not provided here, it can be set later using `from()` +--- before chain methods, or any time before `result()` for lazy chains. +---@param value? any +function Fluent:constructor (value) + self.value = value + + self.queue = {} + self.params = {} + + self.isLazy = false + self.isImmutable = false +end + +--- Defer execution until result() is called. This should be used at the front of the chain. +function Fluent:lazy () + self.isLazy = true + + return self +end + +--- Return new Fluent instance after every chain method. This should be used at the front of the chain. +function Fluent:immutable () + self.isImmutable = true + + return self +end + +--- Constructs a new lazy immutable Fluent instance. +--- This is suitable for use with subroutine methods like `mapSub`. +function Fluent.fn() + return Fluent():lazy():immutable() +end + +--- Internal function facilitating lazy execution +---@protected +---@param func fun(this: cyberbit.Fluent) +function Fluent:_enqueue (func) + return self:_mutate(function (mut) + if mut.isLazy then + mut.queue[#mut.queue + 1] = func + else + func(mut) + end + + return mut + end) +end + +--- Internal function facilitating immutability +---@protected +---@param func fun(mut: cyberbit.Fluent): cyberbit.Fluent +function Fluent:_mutate (func) + func = func or function (v) return v end + + if self.isImmutable then + local clone = self:clone() + + return func(clone) + else + return func(self) + end +end + +--- Clones the Fluent object. Value is copied by reference. +function Fluent:clone () + local clone = Fluent(self.value) + + for k,v in ipairs(self.queue) do + clone.queue[k] = v + end + + for k,v in pairs(self.params) do + clone.params[k] = v + end + + clone.isLazy = self.isLazy + clone.isImmutable = self.isImmutable + + return clone +end + +--- Call a method on the value. +---@param method string Method name +---@param ... any Method arguments +function Fluent:call (method, ...) + local args = {...} + + return self:_enqueue(function (this) + local success, result = pcall(this.value[method], table.unpack(args)) + + if not success then + error('Fluent.call: ' .. result) + end + + this.value = result + end) +end + +--- Set a parameter on the Fluent object. This can be used to pass data between chain methods. +--- Parameters are accessible via `self.params`. +---@param key string Parameter name +---@param value any Parameter value +function Fluent:with (key, value) + return self:_mutate(function (mut) + mut.params[key] = value + + return mut + end) +end + +--- Set the value of the Fluent object. This is typically used when preparing to execute an uninitialized lazy chain. +---@param value any +function Fluent:from (value) + return self:_mutate(function (mut) + mut.value = value + + return mut + end) +end + +--- Convert the value to a boolean using Lua's truthiness rules. +function Fluent:toBool () + return self:_enqueue(function (this) + this.value = this.value and true or false + end) +end + +--- Convert the value to a flag (0 or 1) using Lua's truthiness rules. +function Fluent:toFlag () + return self:_enqueue(function (this) + this.value = this.value and 1 or 0 + end) +end + +--- Convert the value to another using a provided enum table. The value is used as a key to look up the new value. +---@param lookup table +function Fluent:toLookup (lookup) + return self:_enqueue(function (this) + this.value = lookup[this.value] + end) +end + +--- Pass the value through a function and use the return value as the new value. +---@param func fun(value: any): any +function Fluent:transform (func) + return self:_enqueue(function (this) + this.value = func(this.value) + end) +end + +--- Iterate through the value, passing each key-value pair to a function. +--- If the function returns false, iteration will stop. +---@param func fun(key: any, value: any): boolean|nil +function Fluent:each (func) + return self:_enqueue(function (this) + local continue + + for k, v in pairs(this.value) do + continue = func(k, v) + + if type(continue) == 'boolean' and not continue then + break + end + end + end) +end + +--- Filter the value using a function. If the function returns true, the value is kept. +--- If no function is provided, the value is kept if it is truthy. +--- +--- For the inverse of this function, see `reject()` +---@param func? fun(key: any, value: any): boolean +function Fluent:filter (func) + func = func or function (_, v) return v and true or false end + + return self:_enqueue(function (this) + local result = {} + + for k, v in pairs(this.value) do + if func(k, v) then + result[k] = v + end + end + + this.value = result + end) +end +--- Filter the value using a pattern. Elements that match the pattern are kept. +--- Values must be compatible with `string.match`. +--- @param pattern string +function Fluent:filterMatch (pattern) + return self:_enqueue(function (this) + local result = {} + + for k, v in pairs(this.value) do + if string.match(v, pattern) then + result[k] = v + end + end + + this.value = result + end) +end + +--- Filter the value using a lazy Fluent object. Elements that result in the +--- Fluent object returning truthy values are kept. +---@param fl cyberbit.Fluent +function Fluent:filterSub (fl) + return self:filter(function (_, v) + return fl:from(v):result() + end) +end + +--- Find the first element that passes a test function and returns its value. +--- If no function is provided, the first element's value is returned. +--- +--- The iterator used is determined by the first element's key. +--- If it is numeric, `ipairs` is used, otherwise `pairs` is used. +---@param func? fun(key: any, value: any): boolean +function Fluent:first (func) + func = func or function () return true end + + return self:_enqueue(function (this) + local iter = type(next(this.value)) == 'number' and ipairs or pairs + + for k, v in iter(this.value) do + if func(k, v) then + this.value = v + return + end + end + + this.value = nil + end) +end + +--- Find the first element that has a specified key-value pair. +---@param key any +---@param value any +function Fluent:firstWhere (key, value) + return self:first(function (_, v) + return v[key] == value + end) +end + +--- Get a value from the value using a key. If the key does not exist, a default value can be provided. +--- +--- The key can be a dot-separated string to access nested tables: `key1.key2.key3`. +--- If a key is not found (or results in nil), the default value is returned. +--- @param key any +--- @param default any +--- @return cyberbit.Fluent +function Fluent:get (key, default) + return self:_enqueue(function (this) + local keys = {} + + if type(key) == 'string' then + for k in string.gmatch(key, "[^%.]+") do + table.insert(keys, k) + end + else + keys = { key } + end + + local value = this.value + + for k, v in ipairs(keys) do + if type(value) == 'nil' then + value = default + + break + end + + value = value[v] + end + + this.value = type(value) == 'nil' and default or value + end) +end + +--- Group the value by a key. +--- +--- ``` +--- fluent({ +--- { k = 'a', v = 1 }, +--- { k = 'b', v = 2 }, +--- { k = 'a', v = 3 } +--- }):groupBy('k'):result() +--- +--- result = { +--- a = {{ k = 'a', v = 1 }, { k = 'a', v = 3 }}, +--- b = {{ k = 'b', v = 2 }} +--- } +--- ``` +---@param key any +function Fluent:groupBy (key) + return self:_enqueue(function (this) + local result = {} + + for _, v in pairs(this.value) do + result[v[key]] = result[v[key]] or {} + + table.insert(result[v[key]], v) + end + + this.value = result + end) +end + +--- Check if the value has a key. +--- +--- If the key exists but the value is nil, this will return false. +---@param key any +function Fluent:has (key) + return self:_enqueue(function (this) + this.value = this.value[key] ~= nil + end) +end + +--- Get a list of the value's keys. +function Fluent:keys () + return self:_enqueue(function (this) + local result = {} + + for k, _ in pairs(this.value) do + table.insert(result, k) + end + + this.value = result + end) +end + +--- Get the last element that passes a test function. +--- If no function is provided, the last element is returned. +--- +--- The iterator used is determined by the first element's key. +--- If it is numeric, `ipairs` is used, otherwise `pairs` is used. +---@param func? fun(key: any, value: any): boolean +function Fluent:last (func) + func = func or function () return true end + + return self:_enqueue(function (this) + local iter = type(next(this.value)) == 'number' and ipairs or pairs + + local last + + for k, v in iter(this.value) do + if func(k, v) then + last = v + end + end + + this.value = last + end) +end + +--- Iterate through the value, passing each key-value pair to a function. +--- The function can modify the key and value, returning both. +--- +--- If duplicate keys are returned, the last key-value pair will be kept. +--- @param func fun(key: any, value: any): newKey: any, newValue: any +function Fluent:mapWithKeys (func) + return self:_enqueue(function (this) + local result = {} + + for k, v in pairs(this.value) do + local newKey, newValue = func(k, v) + + result[newKey] = newValue + end + + this.value = result + end) +end + +--- Iterate through the value, passing each key-value pair to a function. +--- The function can modify the key's value and return it, updating the value at that key. +---@param func fun(key: any, value: any): newValue: any +function Fluent:map (func) + return self:mapWithKeys(function (k, v) + return k, func(k, v) + end) +end + +--- Iterate through the value, passing each element to a function. +--- The function can modify the value and return it, updating the value at that key. +---@param func fun(value: any): newValue: any +function Fluent:mapValues (func) + return self:map(function (_, v) + return func(v) + end) +end + +--- Iterate through the value, passing each element through a provided lazy Fluent object. +--- The function can call chain methods on the Fluent object as needed. The result of +--- the chain will be the new value at that key. +--- @param fl cyberbit.Fluent +function Fluent:mapSub (fl) + return self:mapValues(function (v) + return fl:from(v):result() + end) +end +--- Get the first match of a pattern using `string.match`. +--- Value must be compatible with `string.match`. +--- @param pattern string +function Fluent:match (pattern) + return self:_enqueue(function (this) + this.value = string.match(this.value, pattern) + end) +end + +--- Select only the specified keys from the value. +---@param keys any[] +function Fluent:only (keys) + return self:_enqueue(function (this) + local result = {} + + for _, key in pairs(keys) do + result[key] = this.value[key] + end + + this.value = result + end) +end + +--- Iterate through the value, reducing to a list of values at the key specified. +--- +--- ``` +--- fluent({ +--- { k = 'a' }, +--- { k = 'b' }, +--- { k = 'c' } +--- }):pluck('k'):result() +--- +--- result = {'a', 'b', 'c'} +--- ``` +---@param key any +function Fluent:pluck (key) + return self:_enqueue(function (this) + local result = {} + + for _, v in pairs(this.value) do + table.insert(result, v[key]) + end + + this.value = result + end) +end + +--- Select one or more key-value pairs from the value at random. +--- +--- If the count is greater than the number of keys, an error will be thrown. +---@param count? integer Number of elements to select, defaults to 1 +function Fluent:random (count) + count = count or 1 + + return self:_enqueue(function (this) + local keys = Fluent(this.value):keys():result() + + if count > #keys then + error('Fluent.random: count is greater than the number of keys') + end + + if count == 1 then + local key = keys[math.random(1, #keys)] + + this.value = this.value[key] + + return + else + local result = {} + + for i = 1, count do + local key = table.remove(keys, math.random(1, #keys)) + + result[key] = this.value[key] + end + + this.value = result + end + end) +end + +--- Reduce the value's elements to a single value using a function. +---@param func fun(initial: any, key: any, value: any): reduction: any +---@param initial any +function Fluent:reduce (func, initial) + return self:_enqueue(function (this) + for k, v in pairs(this.value) do + initial = func(initial, k, v) + end + + this.value = initial + end) +end + +--- Filter the value using a function. If the function returns false, the value is kept. +--- If no function is provided, the value is kept if it is falsy. +--- +--- For the inverse of this function, see `filter()` +---@param func? fun(key: any, value: any): boolean +function Fluent:reject (func) + func = func or function (_, v) return v and true or false end + + return self:_enqueue(function (this) + local result = {} + + for k, v in pairs(this.value) do + if not func(k, v) then + result[k] = v + end + end + + this.value = result + end) +end + +--- Replace key-value pairs with specified key-value pairs. +--- If a key does not exist, it will be created. +--- +--- ``` +--- fluent({ a = 1, b = 2 }):replace({ a = 3, c = 4 }):result() +--- +--- result = { a = 3, b = 2, c = 4 } +--- ``` +---@param value table +function Fluent:replace (value) + return self:_enqueue(function (this) + for k, v in pairs(value) do + this.value[k] = v + end + end) +end + +--- Select only the specified keys from the value's elements. +--- @param keys any[] +function Fluent:select (keys) + return self:_enqueue(function (this) + local result = {} + + for k, v in pairs(this.value) do + result[k] = {} + + for _, key in pairs(keys) do + result[k][key] = v[key] + end + end + + this.value = result + end) +end + +--- Sort the value's elements, optionally with a comparison function. +--- Uses `table.sort` internally. +--- @param func? fun(a: any, b: any): boolean +function Fluent:sort (func) + return self:_enqueue(function (this) + table.sort(this.value, func) + end) +end + +--- Sort the value by a key. +---@param key any +function Fluent:sortBy (key) + return self:sort(function (a, b) + return a[key] < b[key] + end) +end + +--- Sum all the value's elements. +--- +--- If a key is provided, the value at that key is summed. +--- If a groupBy key is provided, the value is grouped by that key and summed. +---@param key? any Key to sum +---@param groupBy? any Key to group by before summing +function Fluent:sum (key, groupBy) + return self:_enqueue(function (this) + if not groupBy then + local sum = 0 + + for _, v in pairs(this.value) do + sum = sum + (key and v[key] or v) + end + + this.value = sum + else + local result = {} + + for _, v in pairs(this.value) do + result[v[groupBy]] = (result[v[groupBy]] or 0) + v[key] + end + + this.value = result + end + end) +end + +--- Pass the value through a function, discarding the return value. This can be used for side effects. +---@param func fun(value: any) +function Fluent:tap (func) + return self:_enqueue(function (this) + func(this.value) + end) +end + +--- Reindex the value's elements numerically. +--- Order of elements is not guaranteed. +function Fluent:values() + return self:_enqueue(function (this) + local result = {} + + for _, v in pairs(this.value) do + table.insert(result, v) + end + + this.value = result + end) +end + +--- Return the final value after processing the chain. This should be called at the end of the chain. +--- For lazy chains, this will execute all queued functions in sequence. +--- For immutable chains, this will not mutate the original Fluent object. +---@return any +function Fluent:result () + if not self.isLazy then + return self.value + end + + local mut = self.isImmutable and self or self:clone() + + for _, func in pairs(mut.queue) do + func(mut) + end + + return mut.value + + -- return self:_mutate(function (mut) + -- for _, func in pairs(mut.queue) do + -- func(mut) + -- end + + -- return mut.value + -- end) +end + +--- Pretty-print the value using `cc.pretty`. +function Fluent:pprint () + return self:_enqueue(function (this) + require('cc.pretty').pretty_print(this.value) + end) +end + +return Fluent \ No newline at end of file diff --git a/src/telem/vendor/init.lua b/src/telem/vendor/init.lua index 93a9d7f..91e7b06 100644 --- a/src/telem/vendor/init.lua +++ b/src/telem/vendor/init.lua @@ -9,6 +9,7 @@ local ecnet2 = require 'ecnet2' local random = require 'ccryptolib.random' local plotter = require 'plotter' local lualzw = require 'lualzw' +local fluent = require 'fluent' return { ccryptolib = { @@ -16,5 +17,6 @@ return { }, ecnet2 = ecnet2, lualzw = lualzw, - plotter = plotter + plotter = plotter, + fluent = fluent } \ No newline at end of file From 9c409dcd9f7cd5c2c55fd4a4e65aeb2e8650e67e Mon Sep 17 00:00:00 2001 From: cyberbit Date: Mon, 10 Jun 2024 01:10:16 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=A6=20add=20plotter=20area=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/telem/lib/output.lua | 1 + .../output/plotter/ChartAreaOutputAdapter.lua | 182 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/telem/lib/output/plotter/ChartAreaOutputAdapter.lua diff --git a/src/telem/lib/output.lua b/src/telem/lib/output.lua index 513a715..9552df1 100644 --- a/src/telem/lib/output.lua +++ b/src/telem/lib/output.lua @@ -14,6 +14,7 @@ return { -- Plotter plotter = { line = require 'telem.lib.output.plotter.ChartLineOutputAdapter', + area = require 'telem.lib.output.plotter.ChartAreaOutputAdapter', }, -- Modem diff --git a/src/telem/lib/output/plotter/ChartAreaOutputAdapter.lua b/src/telem/lib/output/plotter/ChartAreaOutputAdapter.lua new file mode 100644 index 0000000..5debd1b --- /dev/null +++ b/src/telem/lib/output/plotter/ChartAreaOutputAdapter.lua @@ -0,0 +1,182 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' +local vendor +local plotterFactory + +local OutputAdapter = require 'telem.lib.OutputAdapter' +local MetricCollection = require 'telem.lib.MetricCollection' + +local ChartAreaOutputAdapter = o.class(OutputAdapter) +ChartAreaOutputAdapter.type = 'ChartAreaOutputAdapter' + +ChartAreaOutputAdapter.BASELINE = 0 +ChartAreaOutputAdapter.MAX_ENTRIES = 50 +ChartAreaOutputAdapter.X_TICK = 10 + +function ChartAreaOutputAdapter:constructor (win, filter, bg, fg, baseline, maxEntries) + self:super('constructor') + + self:cacheable() + + self.win = assert(win, 'Window is required') + self.filter = assert(filter, 'Filter is required') + + self.plotter = nil + self.plotData = {} + self.gridOffsetX = 0 + + self.filter = filter + + self.bg = bg or win.getBackgroundColor() or colors.black + self.fg = fg or win.getTextColor() or colors.white + self.BASELINE = baseline or 0 + self.MAX_ENTRIES = maxEntries or self.MAX_ENTRIES + + self:register() +end + +function ChartAreaOutputAdapter:register () + if not vendor then + self:dlog('ChartAreaOutputAdapter:boot :: Loading vendor modules...') + + vendor = require 'telem.vendor' + + self:dlog('ChartAreaOutputAdapter:boot :: Vendor modules ready.') + end + + if not plotterFactory then + self:dlog('ChartAreaOutputAdapter:boot :: Loading plotter...') + + plotterFactory = vendor.plotter + + self:dlog('ChartAreaOutputAdapter:boot :: plotter ready.') + end + + self:updateLayout() + + for i = 1, self.MAX_ENTRIES do + t.constrainAppend(self.plotData, self.plotter.NAN, self.MAX_ENTRIES) + end +end + +function ChartAreaOutputAdapter:updateLayout (bypassRender) + self.plotter = plotterFactory(self.win) + + if not bypassRender then + self:render() + end +end + +function ChartAreaOutputAdapter:write (collection) + assert(o.instanceof(collection, MetricCollection), 'Collection must be a MetricCollection') + + local resultMetric = collection:find(self.filter) + + assert(resultMetric, 'could not find metric') + + -- TODO data width setting + self.gridOffsetX = self.gridOffsetX - t.constrainAppend(self.plotData, resultMetric and resultMetric.value or self.plotter.NAN, self.MAX_ENTRIES) + + -- TODO X_TICK setting + if self.gridOffsetX % self.X_TICK == 0 then + self.gridOffsetX = 0 + end + + -- lazy layout update + local winw, winh = self.win.getSize() + if winw ~= self.plotter.box.term_width or winh ~= self.plotter.box.term_height then + self:updateLayout(true) + end + + self:render() + + return self +end + +function ChartAreaOutputAdapter:getState () + local plotData = {} + + for k,v in ipairs(self.plotData) do + plotData[k] = v + end + + return { + plotData = plotData, + gridOffsetX = self.gridOffsetX + } +end + +function ChartAreaOutputAdapter:loadState (state) + self.plotData = state.plotData + self.gridOffsetX = state.gridOffsetX +end + +function ChartAreaOutputAdapter:render () + local dataw = #{self.plotData} + + local actualmin, actualmax = math.huge, -math.huge + + for _, v in ipairs(self.plotData) do + -- skip NAN + if v ~= self.plotter.NAN then + if v < actualmin then actualmin = v end + if v > actualmax then actualmax = v end + end + end + + local flatlabel = nil + + -- NaN data + if actualmin == math.huge then + flatlabel = 'NaN' + + actualmin, actualmax = 0, 0 + end + + -- flat data + if actualmin == actualmax then + local minrange = 0.000001 + + if not flatlabel then + flatlabel = t.shortnum2(actualmin) + end + + actualmin = actualmin - minrange / 2 + actualmax = actualmax + minrange / 2 + end + + self.plotter:clear(self.bg) + + self.plotter:chartGrid(self.MAX_ENTRIES, actualmin, actualmax, self.gridOffsetX, colors.gray, { + xGap = 10, + yLinesMin = 5, -- yLinesMin: number >= 1 + yLinesFactor = 2 -- yLinesFactor: integer >= 2 + -- effective max density = yMinDensity * yBasis + }) + + self.plotter:chartArea(self.plotData, self.MAX_ENTRIES, actualmin, actualmax, self.BASELINE, self.fg) + + local maxString = t.shortnum2(actualmax) + local minString = t.shortnum2(actualmin) + + self.win.setVisible(false) + + self.plotter:render() + + self.win.setTextColor(self.fg) + self.win.setBackgroundColor(self.bg) + if not flatlabel then + self.win.setCursorPos(self.plotter.box.term_width - #maxString + 1, 1) + self.win.write(maxString) + + self.win.setCursorPos(self.plotter.box.term_width - #minString + 1, self.plotter.box.term_height) + self.win.write(minString) + else + self.win.setCursorPos(self.plotter.box.term_width - #flatlabel + 1, self.plotter.math.round(self.plotter.box.term_height / 2)) + self.win.write(flatlabel) + end + + self.win.setVisible(true) +end + +return ChartAreaOutputAdapter \ No newline at end of file From e3d98e512de3337b18fec1b7bf90364758ab6209 Mon Sep 17 00:00:00 2001 From: cyberbit Date: Wed, 12 Jun 2024 02:25:38 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=A6=20rename=20middleware=20a=20bi?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 29 ------------------- src/telem/lib/middleware.lua | 4 +-- src/telem/lib/middleware/CustomMiddleware.lua | 23 +++++++++++++++ .../middleware/HandleCollectionMiddleware.lua | 23 --------------- .../middleware/SortCollectionMiddleware.lua | 23 --------------- src/telem/lib/middleware/SortMiddleware.lua | 23 +++++++++++++++ 6 files changed, 48 insertions(+), 77 deletions(-) create mode 100644 src/telem/lib/middleware/CustomMiddleware.lua delete mode 100644 src/telem/lib/middleware/HandleCollectionMiddleware.lua delete mode 100644 src/telem/lib/middleware/SortCollectionMiddleware.lua create mode 100644 src/telem/lib/middleware/SortMiddleware.lua diff --git a/.vscode/settings.json b/.vscode/settings.json index f3a6de7..4925795 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,34 +1,5 @@ { // "autobuild": true, - "Lua.diagnostics.globals": [ - "colors", - "colours", - "commands", - "disk", - "fs", - "gps", - "help", - "http", - "io", - "keys", - "multishell", - "os", - "paintutils", - "parallel", - "peripheral", - "pocket", - "rednet", - "redstone", - "settings", - "shell", - "term", - "textutils", - "turtle", - "vector", - "window", - "mekanismEnergyHelper", - "require" - ], "triggerTaskOnSave.tasks": { "build": [ "src/telem/**/*.lua" diff --git a/src/telem/lib/middleware.lua b/src/telem/lib/middleware.lua index 78df4c2..77dab9b 100644 --- a/src/telem/lib/middleware.lua +++ b/src/telem/lib/middleware.lua @@ -1,6 +1,6 @@ return { - handleCollection = require 'telem.lib.middleware.HandleCollectionMiddleware', + sort = require 'telem.lib.middleware.SortMiddleware', calcAvg = require 'telem.lib.middleware.CalcAverageMiddleware', calcDelta = require 'telem.lib.middleware.CalcDeltaMiddleware', - sort = require 'telem.lib.middleware.SortCollectionMiddleware', + custom = require 'telem.lib.middleware.CustomMiddleware', } \ No newline at end of file diff --git a/src/telem/lib/middleware/CustomMiddleware.lua b/src/telem/lib/middleware/CustomMiddleware.lua new file mode 100644 index 0000000..fb6041c --- /dev/null +++ b/src/telem/lib/middleware/CustomMiddleware.lua @@ -0,0 +1,23 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' + +local Middleware = require 'telem.lib.BaseMiddleware' + +local CustomMiddleware = o.class(Middleware) +CustomMiddleware.type = 'CustomMiddleware' + +function CustomMiddleware:constructor(handler) + self:super('constructor') + + self.handler = handler +end + +function CustomMiddleware:handle(collection) + assert(collection.type == 'MetricCollection', 'CustomMiddleware:handle :: collection must be a MetricCollection') + + local newCollection = assert(self.handler(collection), 'CustomMiddleware:handle :: handler must return a MetricCollection') + + return newCollection +end + +return CustomMiddleware \ No newline at end of file diff --git a/src/telem/lib/middleware/HandleCollectionMiddleware.lua b/src/telem/lib/middleware/HandleCollectionMiddleware.lua deleted file mode 100644 index 4ab5ff1..0000000 --- a/src/telem/lib/middleware/HandleCollectionMiddleware.lua +++ /dev/null @@ -1,23 +0,0 @@ -local o = require 'telem.lib.ObjectModel' -local t = require 'telem.lib.util' - -local Middleware = require 'telem.lib.BaseMiddleware' - -local HandleCollection = o.class(Middleware) -HandleCollection.type = 'HandleCollection' - -function HandleCollection:constructor(handler) - self:super('constructor') - - self.handler = handler -end - -function HandleCollection:handle(collection) - assert(collection.type == 'MetricCollection', 'HandleCollection:handle :: collection must be a MetricCollection') - - local newCollection = assert(self.handler(collection), 'HandleCollection:handle :: handler must return a MetricCollection') - - return newCollection -end - -return HandleCollection \ No newline at end of file diff --git a/src/telem/lib/middleware/SortCollectionMiddleware.lua b/src/telem/lib/middleware/SortCollectionMiddleware.lua deleted file mode 100644 index 986b251..0000000 --- a/src/telem/lib/middleware/SortCollectionMiddleware.lua +++ /dev/null @@ -1,23 +0,0 @@ -local o = require 'telem.lib.ObjectModel' -local t = require 'telem.lib.util' -local fluent = require 'telem.vendor'.fluent - -local Middleware = require 'telem.lib.BaseMiddleware' -local MetricCollection = require 'telem.lib.MetricCollection' - -local SortCollectionMiddleware = o.class(Middleware) -SortCollectionMiddleware.type = 'SortCollectionMiddleware' - -function SortCollectionMiddleware:constructor() - self:super('constructor') -end - -function SortCollectionMiddleware:handle(collection) - assert(collection.type == 'MetricCollection', 'SortCollectionMiddleware:handle :: collection must be a MetricCollection') - - fluent(collection.metrics):sortBy('name') - - return collection -end - -return SortCollectionMiddleware \ No newline at end of file diff --git a/src/telem/lib/middleware/SortMiddleware.lua b/src/telem/lib/middleware/SortMiddleware.lua new file mode 100644 index 0000000..6cccfd0 --- /dev/null +++ b/src/telem/lib/middleware/SortMiddleware.lua @@ -0,0 +1,23 @@ +local o = require 'telem.lib.ObjectModel' +local t = require 'telem.lib.util' +local fluent = require 'telem.vendor'.fluent + +local Middleware = require 'telem.lib.BaseMiddleware' +local MetricCollection = require 'telem.lib.MetricCollection' + +local SortMiddleware = o.class(Middleware) +SortMiddleware.type = 'SortMiddleware' + +function SortMiddleware:constructor() + self:super('constructor') +end + +function SortMiddleware:handle(collection) + assert(collection.type == 'MetricCollection', 'SortMiddleware:handle :: collection must be a MetricCollection') + + fluent(collection.metrics):sortBy('name') + + return collection +end + +return SortMiddleware \ No newline at end of file From 923df0a66cc52c77ef7c6f2192cc8960aa14f1d1 Mon Sep 17 00:00:00 2001 From: cyberbit Date: Wed, 12 Jun 2024 02:38:59 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=A6=20update=20docs=20and=20bump?= =?UTF-8?q?=20vendor=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/config.mts | 11 ++ docs/assets/middleware-calc-avg.png | Bin 0 -> 8756 bytes docs/assets/middleware-calc-delta.png | Bin 0 -> 15144 bytes docs/getting-started.md | 14 ++- docs/index.md | 2 +- docs/reference/Backplane.md | 15 +++ docs/reference/Middleware.md | 25 ++++ docs/reference/middleware/CalcAverage.md | 68 ++++++++++ docs/reference/middleware/CalcDelta.md | 99 +++++++++++++++ docs/reference/middleware/Custom.md | 117 ++++++++++++++++++ docs/reference/middleware/Sort.md | 53 ++++++++ .../lib/middleware/CalcAverageMiddleware.lua | 2 - src/telem/vendor/init.lua | 2 +- 13 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 docs/assets/middleware-calc-avg.png create mode 100644 docs/assets/middleware-calc-delta.png create mode 100644 docs/reference/Middleware.md create mode 100644 docs/reference/middleware/CalcAverage.md create mode 100644 docs/reference/middleware/CalcDelta.md create mode 100644 docs/reference/middleware/Custom.md create mode 100644 docs/reference/middleware/Sort.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fd24554..492c75d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -30,6 +30,7 @@ export default defineConfig({ { text: 'InputAdapter', link: '/reference/InputAdapter' }, { text: 'OutputAdapter', link: '/reference/OutputAdapter' }, { text: 'Backplane', link: '/reference/Backplane' }, + { text: 'Middleware', link: '/reference/Middleware' }, ] }, { @@ -85,6 +86,16 @@ export default defineConfig({ ] } ] + }, + { + text: 'Middleware', + collapsed: false, + items: [ + { text: 'Sort', link: '/reference/middleware/Sort' }, + { text: 'Calculate Average', link: '/reference/middleware/CalcAverage' }, + { text: 'Calculate Delta', link: '/reference/middleware/CalcDelta' }, + { text: 'Custom', link: '/reference/middleware/Custom' }, + ] } ], diff --git a/docs/assets/middleware-calc-avg.png b/docs/assets/middleware-calc-avg.png new file mode 100644 index 0000000000000000000000000000000000000000..0e67509d118ce7549920c3a93024945b4a69d63c GIT binary patch literal 8756 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 J;kfJF{{o>|qT~Po literal 0 HcmV?d00001 diff --git a/docs/assets/middleware-calc-delta.png b/docs/assets/middleware-calc-delta.png new file mode 100644 index 0000000000000000000000000000000000000000..c0474829023be943a6123931d0d7a73c5efcaa54 GIT binary patch literal 15144 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 + +Middleware is an [abstract](https://en.wikipedia.org/wiki/Abstract_type) class that functions as a mutator for [MetricCollection](MetricCollection) instances at specific points in a cycle, such as just before writing to outputs. Middleware is free to add, modify, and remove metrics from the collection, or replace it entirely if needed. + +## Methods + +### `Middleware` + +Creates a new Middleware instance. + +::: danger +Because Middleware is an abstract class, this constructor should only be called from within classes inheriting `Middleware` by calling `self:super('constructor')`. This will set up any inherited properties with the correct default values. **Behavior of any other use is undefined.** +::: + +### `handle` + +```lua +Middleware:handle (target: MetricCollection): MetricCollection +``` + +Process the provided MetricCollection and return the resulting MetricCollection. In-place modification or replacement are both supported. \ No newline at end of file diff --git a/docs/reference/middleware/CalcAverage.md b/docs/reference/middleware/CalcAverage.md new file mode 100644 index 0000000..ac65ef8 --- /dev/null +++ b/docs/reference/middleware/CalcAverage.md @@ -0,0 +1,68 @@ +--- +outline: deep +--- + +# Calculate Average Middleware + +```lua +telem.middleware.calcAvg (windowSize?: integer) +``` + +Calculates the average of each metric in a collection, observed over a number of cycles. Each average is appended to the collection as a new metric, with an `_avg` suffix appended to the name, and the source set to `middleware`. + + + +## Methods + +### `force` + +```lua +CalcAverageMiddleware:force (): self +``` + +Force the middleware to process metrics from other middleware (`source = 'middleware'`), default is to ignore metrics from other middleware. + +## Usage + +```lua{8} +local telem = require 'telem' +local mw = telem.middleware + +local backplane = telem.backplane() + :addInput('random', telem.input.custom(function () + return { rand = math.random(1, 2) } + end)) + :middleware(mw.calcAvg()) + :cycleEvery(0.1)() +``` + +This will result in the following collection: + + + +Observing the value over time shows that the average converges to the expected value: + +![Output of rand_avg over time](/assets/middleware-calc-avg.png) \ No newline at end of file diff --git a/docs/reference/middleware/CalcDelta.md b/docs/reference/middleware/CalcDelta.md new file mode 100644 index 0000000..c5909aa --- /dev/null +++ b/docs/reference/middleware/CalcDelta.md @@ -0,0 +1,99 @@ +--- +outline: deep +--- + +# Calculate Delta Middleware + +```lua +telem.middleware.calcDelta (windowSize?: integer) +``` + +Measures the delta and rate of each metric in a collection, in both instant and ranged variants. Each measurement is appended to the collection as a new metric, with a suffix appended to the name, and the source set to `middleware`. + +The instant delta is calculated as the difference between the current and previous values, and the instant rate is calculated as the delta divided by the observed time between cycles. The ranged delta and rate are similar, but the previous value is taken from `windowSize` cycles ago. + + + +## Methods + +### `force` + +```lua +CalcDeltaMiddleware:force (): self +``` + +Force the middleware to process metrics from other middleware (`source = 'middleware'`), default is to ignore metrics from other middleware. + +### `interval` + +```lua +CalcDeltaMiddleware:interval (interval: string): self +``` + +Set the interval used to calculate the rate. The default is `1s`, which will result in the rate being calculated in units per second. + +The format is `(unitScale)(unit)`, where `unitScale` is an integer greater than 0, and `unit` is one of: `s` (second), `m` (minute), `h` (hour), `d` (day). Note that "day" is a 24-hour day, not an in-game day. + +## Usage + +```lua{12} +local telem = require 'telem' +local mw = telem.middleware + +local state = 0 + +local backplane = telem.backplane() + :addInput('increments', telem.input.custom(function () + state = state + 1 + + return { inc = state } + end)) + :middleware(mw.calcDelta():interval('1m')) + :cycleEvery(0.1)() +``` + +This will result in the following collection: + + + +Observing the value over time looks something like this: + +![Output of delta middleware over time](/assets/middleware-calc-delta.png) \ No newline at end of file diff --git a/docs/reference/middleware/Custom.md b/docs/reference/middleware/Custom.md new file mode 100644 index 0000000..f065a2e --- /dev/null +++ b/docs/reference/middleware/Custom.md @@ -0,0 +1,117 @@ +--- +outline: deep +--- + +# Custom Middleware + +```lua +telem.middleware.custom (handler: fun(collection: MetricCollection): MetricCollection) +``` + +This middleware wraps a user-provided function for custom middleware implementations. Need to calculate the ratio between two metrics? Count the total number of items from an adapter? Measure a metric relative to an in-game day? Anything is possible! + + + +## Usage + +```lua{25-41} +local telem = require 'telem' +local mw = telem.middleware + +local fluent = require('telem.vendor').fluent + +local backplane = telem.backplane() + :addInput('elements', telem.input.custom(function () + return { + fire = 111, + water = 222, + earth = 333, + air = 444, + } + end)) + + :addInput('rare_elements', telem.input.custom(function () + return { + gold = 777, + silver = 888, + platinum = 999, + } + end)) + + -- calculate the sum of all metrics from the rare_elements adapter + :middleware(mw.custom(function (collection) + local sum = 0 + + for _, metric in ipairs(collection) do + if metric.adapter == 'rare_elements' then + sum = sum + metric.value + end + end + + collection:insert(telem.metric{ + name = 'rare_elements_total', + value = sum, + source = 'middleware' + }) + + return collection + end)) + :cycleEvery(1)() +``` + +This will result in the following collection: + + \ No newline at end of file diff --git a/docs/reference/middleware/Sort.md b/docs/reference/middleware/Sort.md new file mode 100644 index 0000000..08f523e --- /dev/null +++ b/docs/reference/middleware/Sort.md @@ -0,0 +1,53 @@ +--- +outline: deep +--- + +# Sort Middleware + +```lua +telem.middleware.sort () +``` + +Sorts the metrics in a collection by name. + +## Usage + +```lua{13} +local telem = require 'telem' +local mw = telem.middleware + +local backplane = telem.backplane() + :addInput('elements', telem.input.custom(function () + return { + fire = 111, + water = 222, + earth = 333, + air = 444, + } + end)) + :middleware(mw.sort()) + :cycleEvery(1)() +``` + +This will modify the collection to be sorted alphabetically by name: + + \ No newline at end of file diff --git a/src/telem/lib/middleware/CalcAverageMiddleware.lua b/src/telem/lib/middleware/CalcAverageMiddleware.lua index 74e6b57..788092d 100644 --- a/src/telem/lib/middleware/CalcAverageMiddleware.lua +++ b/src/telem/lib/middleware/CalcAverageMiddleware.lua @@ -1,8 +1,6 @@ local o = require 'telem.lib.ObjectModel' local t = require 'telem.lib.util' -local fluent = require('telem.vendor').fluent - local Metric = require 'telem.lib.Metric' local Middleware = require 'telem.lib.BaseMiddleware' diff --git a/src/telem/vendor/init.lua b/src/telem/vendor/init.lua index 91e7b06..07aa5ca 100644 --- a/src/telem/vendor/init.lua +++ b/src/telem/vendor/init.lua @@ -1,6 +1,6 @@ -- Telem Vendor Loader by cyberbit -- MIT License --- Version 0.5.0 +-- Version 0.7.0 -- Submodules are copyright of their respective authors. For licensing, see https://github.com/cyberbit/telem/blob/main/LICENSE if package.path:find('telem/vendor') == nil then package.path = package.path .. ';telem/vendor/?;telem/vendor/?.lua;telem/vendor/?/init.lua' end