Veracity Learning 1.6 and later support plugins! Plugins are collections of JavaScript code that can extend the system to add new capabilities or modify how the system works.
A plugin is a collection of JavaScript code that adds or modifies the functionality of the system. System and LRS type plugins can respond to events in the system which are specifically raised to allow for extension of product. Some events are just for you to respond to - they don't accept any return values. Other events expect a plugin to return some sort of data that will be used later. The Events section below describes most of the common events.
Plugins are added to the system simply by placing their code files under a /plugins/ directory. This directory must be inside the directory that hosts the executable file, and the system must have appropriate permission to read the directory. Write permission in not needed, since Veracity never writes to disk.
Once a plugin file is placed in the plugin directory, it must be activated within the system or an LRS. In the free version, it's assumed that all LRS level plugins are available to each LRS. The Enterprise version has a permissions management function for controlling access to plugins per LRS or per user.
A plugin can be activated multiple times, each time with a different collection of settings.
There are four types of plugins.
System Plugins - These plugins can respond to a set of events that control how system level functions work. They can override the login, add new database tables, attach new routes to the to level paths, or otherwise implement system wide features. System level plugins can also implement any functionality that an LRS level plugins can. System level plugins cannot be controlled per LRS - they are active for the entire system or they are not.
LRS Plugins - These plugins are scoped to a particular LRS within the system. This means that each LRS can be configured to use or not to use any LRS plugin. There is a set of LRS related events that are passed to each installed LRS level plugin. Responding to these events can allow you to integrate with other systems, to modify data in before it's stored or before it's returned to a client, to extend the user interface, or to add features.
Analytic Processors - The old plugin analytics processor feature has been migrated to the new plugin architecture, but generally works as it did before. These plugins create new widgets or expose complex server side logic to back up a graph or chart in the client-side analytics package.
Analytics Dashboards - These plugins work similar to the processors, but instead of creating a single widget, they are used to gather several widgets and parameter pickers into a dashboard. In general, you'll use plugin dashboards and processors together.
You create a plugin by adding a JavaScript file to the /plugins directory. If this directory does not exist, you can create it manually. Once you've created a plugin file, you must restart the server to load it. After the system has been restarted, it will watch the plugin file for changes and reload it automatically if it is edited. The server console will display errors and feedback if there is a problem with your plugin code.
const systemPlugin = require('./utils/plugins/systemPlugin.js');
module.exports = class systemDemo extends systemPlugin {
constructor(odm, settings) {
super(odm, settings);
...
Plugins must inherit from a given base class in order to be recognized by the system. A plugin file MUST export a class that inherits from one of these known types, or it will not be loaded. The above example shows how to create a system plugin.
an LRS plugins should inherit from lrsPlugin
like so:
const lrsPlugin = require('./utils/plugins/lrsPlugin.js');
module.exports = class systemDemo extends lrsPlugin {
constructor(lrs, dal, settings) {
super(lrs, dal, settings);
...
A plugin Dashboard should inherit from the global Dashboard
. Note that the base class Dashboard
is not included by a require call.
module.exports = class CustomDash extends Dashboard {
constructor(params, db) {
super(params, db);
}
...
A plugin Analytic Processor should inherit from the global AnalyticProcessor
. Note that the base class AnalyticProcessor
is not included by a require call.
module.exports = class CustomProcessor extends AnalyticProcessor {
constructor(params, db, lrs) {
super(params, db, lrs);
}
...
A plugin class must also declare a unique name for itself by attaching a property pluginName
to the class definition.
module.exports.pluginName = 'demo';
You can use the typical NodeJS require
keyword to import other libraries, with the follow caveats.
You can also install NPM modules alongside a plugin. Require these as you normally would.
Certain components of the system can be accessed at runtime by requiring them. You can use this method to get the ODM models or routers and controllers that drive the system. When requiring these objects, use the full path from the executable root. The plugin base classes are a good example of this process.
const lrsPlugin = require('./utils/plugins/lrsPlugin.js');
While you won't find that file on the filesystem, the call will still return the object, since it's part of the running software. Be very careful - this give you the ability to interface with the system beyond the defined plugin architecture, and should be avoided except for a few specific use cases.
The system provides several services to plugins.
Most of the functionality is exposed via events that a plugin can listen to. These events include a name and a data payload, and can optionally accept a return value. All plugins are offered the opportunity to listen to all relevant events, and can attach a handler via this.on
as below.
this.on('statementBatch', (event, batch) => {
console.log(batch);
});
The above code handles the statementBatch event as part of an LRS plugin.
A system level plugin differs only in the list of events it has the option to handle. Additionally, system level plugins can hear all events on any lrs. For LRS events, when they are received by a system level plugin, the specific lrs instance which triggered the event is passed as a parameter.
this.on('statementBatch', (lrs, event, batch) => {
console.log(batch);
});
All event handling is asynchronous, and accepts an async function, or a function that returns a promise.
this.on('statementBatch', async (event, batch) => {
await postBatchToMyBISystem(batch);
});
Event parameters are immutable. Attempting to modify the value of an incoming parameter will throw a runtime exception. Use the clone
module to copy parameters if you need to modify them.
It's possible that several plugins will return values for a given event. If this is the case, that last plugin to respond sets the final value that will be used in further processing. Some events are designed to collect all responses and use them as a list.
In order to run logic on a timer, plugins can hook into the system's scheduling system. This is done by requesting an event to fire on an interval, then listening for that event. There is a special API to listen to timing events, onInterval
.
this.every('10 seconds', '10Int');
this.schedule('190 seconds', 'FiresOnce');
The above code sets up a recurring event that will be sent to the plugin every 10 seconds, and one event that will fire only once, in 190 seconds. The plugin should handle this like any other event.
this.onInterval('10Int', async (e) => {
console.log('This prints every 10 seconds, or the shortest interval.);
});
this.onInterval('FiresOnce', async (e) => {
console.log('This prints once);
});
While there is currently no API for clearing intervals, removal or deactivation of the plugin will clear all pending events in the schedule.
Plugins must be stateless. You cannot count on data that you stored in global memory being present the next time a handler is called. This restriction allow plugins to work in a multi-server environment, and allows the system to manage the lifecycle and resources consumed by plugins. To keep track of some value beyond the invocation of a handler, you can persist that data in the plugin state.
const state = await this.getState();
if (!state.count) { state.count = 1; } else { state.count++; }
await this.persistState(state);
State is managed on a per plugin activation basis, so if the same plugin is added to the system or an LRS twice, each gets its own state. When a plugin activation is removed, state is irretrievably destroyed.
You can use a standard express.js
router to handle requests and responses, in order to hook up paths on the server to services you plugin provides. These routers must be registered via an API call to tell the system where they should be mounted in the request handling process.
router = express();
this.router.get('/settings', (req, res, next) => {
res.send(settings);
});
this.setRouter('lrs', router);
The setRouter
command tell the system that the path should be attached at the LRS UI level. So, for the path /settings
, the actual url on the server would be/ui/lrs/lrsname/plugins/{pluginuuid}/settings
You can get the value of {pluginuuid}
via this.uuid
. For convenience, we also offer this.getLink(path,'lrs')
.
LRS plugins can attach routers at 'lrs' and 'portal', while system plugins can attach routers at 'lrs', 'portal' and 'system'.
You can also render Handlerbars templates. User provided templates should be placed in ./views/templates
, and should end in the '.hbs' extension.
this.router.get('/showAPage', (req, res, next) => {
res.render("/templates/mypage");
});
Plugins can request to run code when they are activated in an LRS or in the system, or when removed. The system handles orchestrating these events in a multi processor or multi-server system such that they run only once, regardless of the number of servers.
async install() {
console.log('install me ' + this.uuid);
}
async uninstall() {
console.log('uninstall me ' + this.uuid);
super.uninstall();
}
It's important to call super.uninstall()
in order to clean up scheduled events.
Every activation of a plugin comes with a block of settings data. If a plugin is activated multiple times, each gets its own settings block. These settings are defined by the user in the UI or the API, and passed to the class constructor.
constructor(lrs, dal, **settings**) {
super(lrs, dal, **settings**);
The plugin author can define the form that is shown for populating the settings. You do this by implementing a static getter function for settingsForm
, which returns a list of controls.
static get settingsForm() {
return [
{
label: 'String with client side validation',
id: 'nameOfProperty',
helptext: 'User should type a string',
validation: "val !== undefined && val !== '' && val.length > 2 && val.length < 100",
validationMessage: 'Enter a string',
placeholder: 'Show this as the place holder',
type: { isText: true, type: 'text' },
},
{
label: 'Checkbox',
id: 'checkbox',
helptext: 'This is either true or false',
type: { isCheck: true },
},
{
label: 'A Select',
id: 'select',
helptext: 'What should you pick',
type:
{ isSelect: true },
options: [
{
text: 'I attempted it',
value: 'http://adlnet.gov/expapi/verbs/attempted',
},
{
text: 'I attended it',
value: 'http://adlnet.gov/expapi/verbs/attended',
},
],
},
];
}
Finally, in order to display the plugin in the plugin activation GUI, the plugin must implement some metadata fields. These are not optional, and must be defined as below
static get display() {
return {
title: 'Demo',
description: 'A demo plugin loaded from the filesystem',
};
}
// Additional metadata for display
static get metadata() {
return {
author: 'Veracity Technology Consultants',
version: '1.0.0',
moreInfo: 'https://www.veracity.it',
};
}
A plugin Analytic Processor is a more tightly constrained tool. It does not have access to any of the above services.
A basic plugin Analytics Processor looks like this:
module.exports = class MyProcessor extends AnalyticProcessor{
constructor(params,db,lrs) {
super(params,db,lrs);
console.log("Wow, in the derived constructor!",params);
this.pipeline = [
...CommonStages(this,{
range:true,
limit:true
}),
{
$match: {
"statement.object.id": this.param("activity")
}
},
{
$limit: 10
},
{
$group: {
_id: "$statement.actor.id",
count: {
$sum: 1
}
}
}
];
this.chartSetup = new BarChart("_id","count");
this.map = MapToActorNameAsync("_id");
}
map(val)
{
// console.log(val);
return val;
}
filter(val)
{
return Math.random() > .5;
}
exec(results)
{
console.log(results);
return results;
}
static getConfiguration() {
let conf = new ProcessorConfiguration("Demo", ProcessorConfiguration.widgetType.graph, ProcessorConfiguration.widgetSize.small);
conf.addParameter("activity", new ActivityPicker("Activity","Choose the activity to plot"), true);
return conf;
}
}
Notice how the constructor extends AnalyticProcessor, and sets this.pipeline. This is a MongoDB Aggregation processor. Unlike the Aggregation Widget above, it "parameterizes" a part of the query by adding this.param("activity")
at a certain point. The static method getConfiguration tells the GUI a bit about how to display the parameters. It says, basically, that the user should pick a value for the "activity" parameter, and that they should be offered choices from the systems registry of xAPI "activities". There are various picker types available, listed below. Note that this technology is behind all the built-in widget types!
You can also see that the class has map
, filter
,and exec
functions. These allow you to execute some JavaScript on the results of the Aggregation query, for cases where you just can't get the logic into a Mongo Aggregation Pipeline. Each of these functions may even perform asynchronous work using the async
keyword in ES6.
These functions are called in the order: filter, map, exec.
In the example above, you can see that in the constructor, the map function (this.map
) is replaced. We generate a new mapping function by calling the utility MapToActorNameAsync. This utility will replace the value of "_id" in each result with the actor's name, by looking up the actor from the system's registry where the results '_id' property value is the IFI for the actor. So, an object that looks like this:
{
_id:"mailto:[email protected]",
averageScore:"100",
daysMissed:0,
count:100
}
becomes this:
{
_id:"Rob Chadwick",
averageScore:"100",
daysMissed:0,
count:100
}
The constructor also sets the chartConfig in the line this.chartSetup = new BarChart("_id","count");
This generates a new BarChart where the bars are named by the value of the _id
field, and the height of the bars is read from the count
field. This mirrors the configuration of the Chart Config in the Aggregation Widget, so read over that documentation for more info. The structure of the chartConfig
field depends on the engine
value, and is documented below.
So, now you can see a basic set up, let's list the various utilities we provide for you to use.
Every processor must export a configuration object from a static method called getConfiguration
static getConfiguration() {
let conf = new ProcessorConfiguration("Demo", ProcessorConfiguration.widgetType.graph, ProcessorConfiguration.widgetSize.small);
conf.addParameter("activity", new ActivityPicker("Activity","Choose the activity to plot"), true);
return conf;
}
This configuration sets a few things.
ProcessorConfiguration(title:String,type:Enum,Size:Enum)
A processor configuration also has a few functions you can call to set other options.
5 seconds
or 10 minutes
or 3 days
. Sets the amount of time the analytics will be cached.this.param(parameterName)
this.param
when the user does not supply a valueUsed to tell the system what sort of GUI to present the user when they are configuring a widget based on the processor. Each should be created with new
. The values are in the global scope, so you can type new Text("user sees this")
.
These utilities make it easier for you to specify common mapping transforms. Each is a function in the global scope. The return value of these functions are themselves functions, and should be assigned to this.map
. For instance:
this.map = MapToCourseName("_id");
this.map = MapToActorName("_id","name")
and the input object is: {
_id: {
mbox:"mailto:[email protected]",
account:{
name:"Rob C",
homePage:"https://www.veracity.it"
}
}
}
The mapping function would output {
_id: {
mbox:"mailto:[email protected]",
account:{
name:"Rob C",
homePage:"https://www.veracity.it"
}
},
name:"Rob C"
}
Processors use the chartConfig
field to store configuration for their widget renderer. Most widgets in Veracity Learning LRS are of the type graph
. This is set in the constructor of the ProcessorConfiguration object. A widget with the type graph
or table
requires additional information on how to build the graph, and how it maps to the results of the query. Similar to the constructor
field in the Chart Setup in the Aggregation Widget, we allow you to call on some common constructors to build the JSON object that represents the graph that is drawn into the widget. We expose some common types with global constructors, but you aren't limited to these. Check out AmCharts for full documentation on the possibilities. You can set the value of this.chartConfig
in the constructor or in the exec
function. Call these utilities with the new
keyword in the global scope.
this.chartConfig = new BarChart("_id","count");
This creates an AmCharts configuration object that looks like this. Setting the value to the below JSON is identical.
{ forWidgetType: 'graph',
balloon:
{ borderThickness: 0,
borderAlpha: 0,
fillAlpha: 0,
horizontalPadding: 0,
verticalPadding: 0,
shadowAlpha: 0 },
export: { enabled: true, fileName: 'Veracity_data' },
type: 'XYChart',
labelsEnabled: false,
engine: 'amcharts4',
colors:
{ list:
[ '#00BBBB',
'#006E6E',
'#159800',
'#001F7C',
'#1FE200',
'#0133C8',
'#00BBBB',
'#006E6E',
'#159800',
'#001F7C',
'#1FE200',
'#0133C8',
'#00BBBB',
'#006E6E',
'#159800',
'#001F7C',
'#1FE200',
'#0133C8' ] },
xAxes:
[ { id: 'c1',
type: 'CategoryAxis',
dataFields: { category: '_id' },
renderer:
{ minGridDistance: 60,
grid: { strokeOpacity: 0.05 },
labels:
{ rotation: 45,
truncate: true,
maxWidth: 200,
verticalCenter: 'top',
horizontalCenter: 'left' } } } ],
exporting: { menu: {} },
yAxes:
[ { id: 'v1',
type: 'ValueAxis',
dataFields: { value: 'count' },
renderer: { grid: { strokeOpacity: 0.05 } } } ],
series:
[ { id: 's1',
xAxis: 'c1',
yAxis: 'v1',
type: 'ColumnSeries',
name: 'Series Title',
stacked: false,
columns: { tooltipText: '{categoryX}:{valueY}' },
colors:
{ list:
[ '#00BBBB',
'#006E6E',
'#159800',
'#001F7C',
'#1FE200',
'#0133C8',
'#00BBBB',
'#006E6E',
'#159800',
'#001F7C',
'#1FE200',
'#0133C8',
'#00BBBB',
'#006E6E',
'#159800',
'#001F7C',
'#1FE200',
'#0133C8' ] },
dataFields: { categoryX: '_id', valueY: 'count' } } ],
}
Notice the final line, dataFields: { categoryX: '_id', valueY: 'count' } } ]
. This is where the BarChart
constructor uses its parameters. The rest of this is the default configuration for a BarChart in Veracity. It sets up the colors, patterns, themes and legend that are used for all BarCharts in our system. There are a few additional values on this object that are worth discussing:
BarChart (category,value)
A bar chart where the bars are labeled by the category field, and the value comes from the value field.
PieChart (category,value)
A pie chart where the bars are labeled by the category field, and the value comes from the value field.
SerialChart (category,value)
An XY chart where the X axis is the value of "category", and the Y axis is the value of "value" from each result document.
MultilineChart (lines,categoryField)
An XY chart with multiple lines. categoryField is the name of the X value for each datapoint, and lines is an array of string that represent each Y value. For instance, if the data looks like this:
[
{line1:10,line2:20,line3:23,Xval:1},
{line1:11,line2:24,line3:3,Xval:2}
...
{line1:103,line2:2,line3:365,Xval:10}
]
Then to generate a proper multiline chart, you would set the chart config as:
this.chartSetup = new MultilineChart(["line1","line2","line3"],"Xval");
ErrorBars (categoryField, valueField, errorField)
An XY chart that shows crosses to represent a value and a range around the value. categoryField and valueField work just like a BarChart, and errorField represents the range size around valueField in the center.
StackedBarChart (categoryField,stacks)
A bar chart where each bar is subdivided into stacks. CategoryField is the name (and grouping key) for each bar, and stacks is an array of strings that are the keys for the value fields. Data will be automatically processed, so you can provide a series of documents like this:
[
{name:"Rob",courseA:10}
{name:"Tim",courseB:0}
{name:"Rob",courseB:130}
{name:"Tim",courseA:50}
]
Then to generate a proper stacked bar chart, you would set the chart config as:
this.chartSetup = new StackedBarChart("name",["courseA","courseB"]);
Table (column1,column2,...)
A data table, where each column is one of the parameters from the constructor. This ChartConfig is only useful when the WidgetType is "table".
Not every widget needs to be a graph rendered by a chart engine. We have a handful of other Widget Types that render various HTML.
chartConfig
to new Table(...)
when using this type. The constructor for Table
is documented above.this.exec
function. Only the first value in the array is used.[
{
icon: 'fa-check', //A Font Awesome icon class
change: '10 Attempts', //The title value
subtext: '', //A smaller line of text underneath
},
];
[
{
icon: 'fa-check', //A Font Awesome icon class
title: '10 Attempts', //The title value
subtext: '', //A smaller line of text underneath
color:"red" //A CSS color value
}
...
];
You can use ElasticSearch instead of MongoDB for your queries. To do so, you should extend your Processor from a different base class, ElasticAnalyticProcessor
. When using this base class, the value of this.pipeline is meaningless. Instead, you must populate this.query
and this.aggregation
. The this.query
value is an array of ElasticSearch DSL fragments, and this.aggregation
is a full ElasticSearch aggregation. Note that you must include an aggregation. The results from this aggregation are piped into your map
,filter
, and exec
functions.
Be sure to initialize the query with this.query = this.commonQueryStages({ range: true });
if you want to obey the dashboard global time range.
Using ElasticSearch is VASTLY faster for most common queries and analytics, so prefer this option unless you need to execute complex queries that can only be represented as a MongoDB Aggregation Pipeline.
class ESCompletionsByStudent extends ElasticAnalyticProcessor {
constructor(parameters, db, lrs) {
super(parameters, db, lrs);
this.query = this.commonQueryStages({ range: true });
if (this.param('verb')) {
this.query.push({
term: {
'verb.id': this.param('verb'),
},
});
}
if (this.param('object')) {
this.query.push({
term: {
'object.id': this.param('object'),
},
});
}
this.aggregation = {
terms: {
field: 'actor.id',
size: 10,
order: {
_count: 'desc',
},
},
};
// find the name of each actor by actorID
if (this.param('verb')) this.map = multiMap(mapToActorNameAsync('key', 'actorName'), mapToVerbDisplay('verb', 'verb'));
if (!this.param('verb')) this.map = mapToActorNameAsync('key', 'actorName');
this.chartSetup = new BarChart('actorName', 'doc_count');
}
If you wish not to use the MongoDB or ElasticSearch interfaces we provide, you can override the compute
function. This function is async
and should return an array of values that will be piped into map
,filter
, and exec
.
this.compute = async function GetDataFromAnotherServer()
{
let url = this.param('url');
let request = require('request');
let values = await request.get(url);
return values;
}
This notional example returns some value from another server, where the server address is a parameter. You can use this method to generate any analysis algorithm you wish.
Plugins interact with the system primarily by responding to events. Each event brings with it a certain set of data, and expects a certain output. Here's an incomplete list of common events that you may want to respond to. Note that not every plugin will receive every event - it depends on the plugin type and permission settings.
This is the single most used event for plugin authors. This event informs your plugin that a new xAPI statement batch was received, validated and stored into the database. The only parameter (batch in the example below) is an array of statement records. These records include the indexes we create like "voided" and the "duration" in milliseconds, as well as the normalized statement. The normalized statement is found at batch[*].statement
;
Handle this event in order to synchronize your data with another system like a BI (Business Intelligence) tool, database, search engine, or AI processor. You can also watch for specific events and trigger some event, like posting an HTTP request when a given student completes a given class.
Example
this.on('statementBatchStored', (e, batch) => { this.processBatch(batch); });
Expects Return
undefined - no data should be returned
Parameters
This event is fired when something changes in the system level database. This is for informational purposes only.
Example
this.on('systemODMEvent', (e, method, type, model)
Expects Return
undefined - no data should be returned
Parameters
This event is fired when something changes in the LRS level database for a particular LRS. This is for informational purposes only.
Example
this.on('LRSODMEvent', (e, method, type, lrs, model)
Expects Return
undefined - no data should be returned
Parameters
The system has started.
Example
this.on('systemStartup', (e) ={})
Expects Return
undefined - no data should be returned
Parameters
*no parameters"
The system has received an HTTP request to one of the UI paths.
Example
this.on('uiRequest', (e, req) => {
this.prodLog(colors.cyan('Logger:') + colors.underline('UI'), colors.green(req.method), req.url, colors.red(req.user ? req.user.email : ''));
});
Expects Return
undefined - no data should be returned
Parameters