Giter Site home page Giter Site logo

apache / flagon-useralejs Goto Github PK

View Code? Open in Web Editor NEW
24.0 11.0 23.0 5.33 MB

Apache Flagon UserALE.js is a comprehensive, thin-client behavioral logging tool

Home Page: https://flagon.apache.org/

License: Apache License 2.0

HTML 13.12% JavaScript 84.92% CSS 1.71% Dockerfile 0.24%
flagon apache behavioral-analytics business-analytics usability usage user-monitoring node-js node js

flagon-useralejs's Introduction

Apache Flagon UserALE.js

Node.js CI Known Vulnerabilities Maintenance npm License Node.js

Apache UserALE.js is part of the Apache Flagon Project. It is a client side instrumentation library written in JavaScript designed for easy deployment and lightweight configuration in gathering logs from your web applications for user behavioral analytics.

Once included in your project, Apache UserALE.js provides a comprehensive behavioral logging capability, capturing every event on every element rendered in your DOM.

Additional documentation and a demonstration can be found at the Apache Flagon website.

Quickstart Guide

  1. Include UserALE.js in your project as either a module or script tag
  2. Set up a logging end-point. Try our example server utility or try out our Elasticsearch (ELK) stack example.
  3. Configure UserALE.js settings using our API, including where to POST logs to (port:8000 for UserALE example or port:8100 of ELK/Logstash)
  4. Further explore the UserALE.js API to customize your log feed, add filters, custom logs, and modify logs themselves. Explore a few examples here and a wider set in our example utility.
  5. Visualize and analyze your logs. See our sample kibana dahsboards for behavioral analytics.

Table of Contents

What's New
Installation
Configure
Usage
Examples
Indexing, Storing, and Visualizing Logs
Modifying Source
Contributing
License

What's New in Version 2.4.0?

  • Refactors Map and Filter APIs as generalized callbacks for functionality
  • Adds additional UserALE.js API examples utilizing generalized callbacks
  • Minor updates to update deprecated downstream dev dependencies
  • Minor changes to documentation, updated examples

See our CHANGELOG for a complete list of changes.

Installation

Either through cloning our source repo or by using npm:

npm install flagon-userale

or 

npm install flagon-userale --engine-strict (enforces supported Node.js versions)

To include UserALE.js as an object in your project, include as a module:

import * as userale from 'flagon-userale';

or

const userale = require('flagon-userale');

Our webpack example illustrates this use-case.

You can also include UserALE.js as a script-tag. A pre-built version of the userale script is included in our package and repositories:

<script src="./node_modules/flagon-userale/build/userale-2.4.0.min.js"></script>

Our script tag example illustrates this use-case

If you include UserALE.js as a script-tag, consider installing via npm as a development dependency:

npm install --save-dev flagon-userale

Or if you want to use a CDN, then you can use something like

<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/userale-2.4.0.min.js"></script>

We also support a WebExtension that can be added to your browser in developer mode. Follow the link for instructions.

Once UserALE.js is installed in your project, your application will start generating logs automatically.

Configure

Some configuration is necessary. At minimum you will need to provide UserALE.js an end-point to ship logs to; default behavior is to ship logs to localhost:8000.

NOTE: In order to facilitate testing configuration and usage of UserALE.js, we have included an example logging server in our example directory. This is a very helpful utility that works with both included module examples and script-tag examples. We strongly recommend experimenting with it.

Configuration details follow:

If you have included UserALE.js in your project as a module, you will need to use our userale.options() function, which exposes library configuration options through our API.

For example, if you do not want UserALE.js to start logging automatically, you can modify this behavior through the userale.options() API (autostart config).

Then, you can use the userale.start() API export to begin logging at the appropriate time during page load or triggered from an event:

const changeMe = "me";
userale.options({
    "userId": changeMe,
    "autostart": false,
    "url": "http://localhost:8000/",
    "version": "next",
    "logDetails": false,
    "sessionID": "this one"
});

userale.start();

Additional examples of userale.options() can be found in our example directory.

The complete list of configurable parameters that can be configured via userale.options() is:

Param Description Default
url Logging URL http://localhost:8000
autostart Should UserALE.js start on page load true
transmitInterval Delay between transmit checks 5000 (ms)
logCountThreshold Minimum number of logs to send 5
userId User identifier null
sessionID Session identifier null
version Application version identifier null
logDetails Toggle detailed logs (keys pressed and input/change values) false
resolution Delay between instances of high frequency logs (mouseover, scroll, etc.) 500 (ms)
userFromParams Query param in the page URL to fetch userId from null
toolName Name of tool being logged null
authHeader Authorization header to be passed to logging endpoint null

If you have included UserALE.js as a script-tag in your project, you can use HTML data parameters to pass configuration options to the library through the script tag. For example:

  <script
          src="./node_modules/flagon-userale/build/userale-2.4.0.min.js"
          data-url="http://localhost:8000/"
          data-user="example-user"
          data-version="2.4.0"
          data-tool="Apache UserALE.js Example"
  ></script>

You have access to the same parameters listed above, however, naming conventions vary slightly for use in HTML:

Param Description Default
data-url Logging URL http://localhost:8000
data-autostart Should UserALE.js start on page load true
data-interval Delay between transmit checks 5000 (ms)
data-threshold Minimum number of logs to send 5
data-user User identifier null
data-version Application version identifier null
data-log-details Toggle detailed logs (keys pressed and input/change values) false
data-resolution Delay between instances of high frequency logs (mouseover, scroll, etc.) 500 (ms)
data-user-from-params Query param in the page URL to fetch userId from null
data-tool Name of tool being logged null
data-auth Authorization header to be passed to logging endpoint null

If you are using our WebExtension, you can modify some of these parameters via the extensions' options page.

Usage

Including UserALE.js in your project as a module attaches the UserALE.js script as an object to the page.

We have exposed a number of functions that assist you in modifying, filtering, and customizing logs

A complete list of available functions are as follows:

Function Description Notes
userale.options modify userale's configuration option see top level README for complete list of options
[DEPRECATED] userale.filter filters out logs from logging queue by keys or values filters are callbacks with global scope
[DEPRECATED] userale.map modify/add log keys or values mappings are callbacks with global scope
userale.addCallbacks add one or more callbacks to be executed during log packaging callbacks have global scope
userale.removeCallbacks remove one or more callbacks by name Removes callbacks added from userale.addCallbacks
userale.log appends a custom log to the log queue the custom log object is an object key:value pairs
userale.packageLog transforms the provided event into a log and appends it to the log queue designed for HTML events
userale.packageCustomLog packages the provided customLog to include standard meta data and appends it to the log queue designed for non HTML events
userale.details defines the way information is extracted from various events supports packageLog/packageCustomLog 'details'
userale.getSelector builds a string CSS selector from the provided HTML element id populates 'target' field in packaged logs
userale.buildPath builds an array of elements from the provided event target, to the root element (DOM path) populates the 'path' field in packaged logs
userale.start used to start the logging process if unecessary if 'autostart' is set to true in initial setting (default)
userale.stop halts the logging process. Logs will no longer be sent will need to invoke userale.start to restart logging

Including UserALE.js as a script-tag provides you access to the same functions listed above. However, UserALE.js essentially becomes a property of the DOM. As such, you'll need to call functions as a window property:

userale.options = window.userale.options

Examples

We provide a number of examples to illustrate how the functions above can be used with sample webpages and logging servers. These are tailored for module examples and script-tag examples. Select examples are below:

Filter your logs with userale.filter:

userale.filter(function (log) {
    var type_array = ['mouseup', 'mouseover', 'mousedown', 'keydown', 'dblclick', 'blur', 'focus', 'input', 'wheel'];
    var logType_array = ['interval'];
    return !type_array.includes(log.type) && !logType_array.includes(log.logType);
});

Modify (add/remove) log fields with surgical precision using userale.map:

userale.map(function (log) {
        var targetsForLabels = ["button#test_button"];
        if (targetsForLabels.includes(log.target)) {
            return Object.assign({}, log, { CustomLabel: "Click me!" });
        } else {
            return log;  
        } 
      });

(Additional examples illustrate precision custom labeling, using a variety of functions.)

Generate custom logs with userale.log:

document.addEventListener('change', function(e) {
    if (e.target.value === 'log') {
        userale.log({
            target: userale.getSelector(e.target),
            path: userale.buildPath(e),
            type: e.type,
            logType: 'custom',
            userAction: false,
            details: 'I can make this log look like anything I want',
            customField1: 'foo',
            customField2: 'bar',
            userId: userale.options().userId,
            toolVersion: userale.options().version,
            toolName: userale.options().toolName,
            useraleVersion: userale.options().useraleVersion,
            sessionID: userale.options().sessionID,
            customLabel: "(custom) Log Example"
        });
    }
});

User our own log packaging pipeline to streamline custom HTML event logging with userale.packageLog:

document.addEventListener('change', function(e){
    if (e.target.value === 'packageLog') {
        /**You can then use the 'Mapping' API function to modify custom logs created with the packageLog function*/
        userale.map(function (log) {
            var targetsForLabels = ['change'];
            if (targetsForLabels.includes(log.type)) {
                return Object.assign({}, log, { logType: 'custom', customLabel: 'packageLog Example' });
            } else {
                return log;
            }
        });
        /**You can also use the details function to package additional log meta data, or add custom details*/
        userale.packageLog(e, userale.details(userale.options(),'change'));
    } else {
        return false
    }
});

Again, see Usage for differences in invoking these functions with module and script-tag includes.

You can find additional examples on our website.

Indexing, Storing and Visualizing Logs

We recommend Elastic products, specifically an ELK cluster, for indexing and storing logs in productions.

You can find a 'sand-box' ELK build, configuration files, and visualization/dashboards tailored for UserALE.js in the Apache Flagon parent repository.

We also provide some documentation about stack-considerations on our project website.

Modifying Source

You may wish to modify UserALE.js to suite your needs. After making modification to UserALE.js src, you will need to rebuild the UserALE.js script (and run tests).

To (re)build UserALE.js:

npm run build

To run UserALE.js unit tests:

npm run test

We use gulp-mocha for unit tests. The results will print to your terminal:

...
    attachHandlers
    ✓ attaches all the event handlers without duplicates
    ✓ debounces bufferedEvents (505ms)
    defineDetails
      - configures high detail events correctly
...

  45 passing (954ms)
  1 pending

Any failing tests will also be logged in the terminal. If there are failing tests, please consider submitting an issue report.

For more guidance on modifying Flagon UserALE src code, check out the guide on our website.

Contributing

Contributions are welcome! Simply submit an issue. Pull requests are welcome. The core team will review it and work with you to incorporate it into UserALE.js. If you want to become a contributor to the project, see our contribution guide.

Join the conversation: tell us your needs, wishes, and interests by joining our mailing list!

License

Apache Flagon UserALE.js is provided under Apache License version 2.0. See LICENSE and NOTICE files at MASTER for more details.

flagon-useralejs's People

Contributors

amirmghaemi avatar brucearctor avatar claygimenez avatar confusingstraw avatar dameikle avatar dependabot[bot] avatar eandrewjones avatar jyyjy avatar kevbrowndev avatar lewismc avatar michellebeard avatar poorejc avatar unclegedd avatar vl8x avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

flagon-useralejs's Issues

Add configurable filter for different log 'types' in browser extension options

Currently the browser extension has limited configurability through /UserAleWebExtension/options.js--userAleHost, userAleScript, toolUser, toolName, toolVersion. As such, the embedded userale-X.x.x.js script logs everything. It would be good to embed a filter in ./background.js that receives user inputs through opstionsPage.js and options.js. A nice example example of a filter can be found in our /example dir:

window.userale.filter(function (log) { var type_array = ['mouseup', 'mouseover', 'mousedown', 'keydown', 'dblclick', 'blur', 'focus', 'input', 'wheel']; var logType_array = ['interval']; return !type_array.includes(log.type) && !logType_array.includes(log.logType); });
Would be nice to include a form object that would allow users to add (through drop down or simple text input) event 'types' (e.g., mousedown, mouseup, mouseover) and log 'types' (e.g., 'raw', 'interval') (aside: maybe toggle for 'interval') to exclude from the browser extension log stream.

Index.js is not found when running the example page

Currently, when running npm run example:run on a fresh build and navigating tolocalhost:8000, the browser console throws the following error:

GET http://localhost:8000/index.js net::ERR_ABORTED 404 (Not Found)

This is because in the Express app we are not using the directory that index.js is located in. This results in the index.js not being loaded and none our custom Userale settings from that file are being used

Update nodaemon example server to accept JSON from navigator.sendbeacon

On https://github.com/apache/incubator-flagon-useralejs/tree/flagon-userale-50

sendOnClose is now executed through navigator.sendbeacon (only) triggered on visibility state change to 'hidden'. This appears to reliably break the example test server

TypeError: req.body.forEach is not a function
    at /Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/example/server.js:77:12
    at Layer.handle [as handle_request] (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/layer.js:95:5)
    at next (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/route.js:137:13)
    at Route.dispatch (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/route.js:112:3)
    at Layer.handle [as handle_request] (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/layer.js:95:5)
    at /Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/index.js:281:22
    at Function.process_params (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/index.js:335:12)
    at next (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/index.js:275:10)
    at jsonParser (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/body-parser/lib/types/json.js:119:7)
    at Layer.handle [as handle_request] (/Users/jpoore/Documents/Apache_Flagon/test/incubator-flagon-useralejs/node_modules/express/lib/router/layer.js:95:5)

see #50 for more detail

Add new initial setting for customizing a log package to be used by packageLog

Might be nice if instead of requiring users to use our native log schema, users to specify their own in options API and use it with automated logging and custom logs.

Imagine new field in options API. If is not null, then packageLog function uses some other object instead. May require exporting more functions for clientTime, etc., etc. to make it convenient for users to populate fields.

Capture what tab# page is rendered in

We get this via the browser plugin, however, that is a property of browser, which the page doesn't have access to. Will look into it. Likely requires a server-side solution

sessionID not written properly

Fresh loaded UserALE creates sessionIDs that are malformed. Until the page is refreshed these sessionID looks like htis in JSON:

"sessionID":""session_1613853205259""

When the page is reloaded:

"sessionID":"session_1613853205259"

At page load, getInitialSettings checks for a value in 'data-session' (userale.options). If it doesn't find it will do one of two things:

  1. it will check sessionStorage for a userAleSessionId key and if it finds one it will pass that to getInitialSettings
  2. if it doesn't find that key it will create a new one and set the key and return the key that it sets.

Looks like the returned value for the case where no key exists is JSON.stringify(value). When a key exists, the returned value is JSON.parse(window.sessionStorage.getItem(sessionKey)). Either fresh Session ID's need to not be stringified or need to be parsed after strigified.

NPM deployment automatically starts regardless of usage

The flagon-userale NPM package has a handful of issues. The most egregious (in my eyes) is that, even if I don't explicitly start it, it will begin collecting/sending logs as soon as it is imported. Maybe there is some argument that this is intended behavior, but having it do this is probably a surprise for developers. For example, this React project results in log collection/sending:

// in index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
// in App.jsx
import { packageCustomLog } from "flagon-userale";
import { useCallback, useEffect, useState } from "react";

function App() {
  const [secondsElapsed, setElapsed] = useState(0);
  const resetTimer = useCallback(() => {
    setElapsed(0);
    packageCustomLog({ elapsed: secondsElapsed, logType: "timerReset" }, undefined, false);
  }, [secondsElapsed, setElapsed]);

  useEffect(() => {
    const timerId = setTimeout(() => {
      setElapsed(secondsElapsed + 1);
      packageCustomLog({ elapsed: secondsElapsed, logType: "timerUpdate" }, undefined, false);
    }, 1000);

    return () => {
      clearTimeout(timerId);
    };
  }, [secondsElapsed, setElapsed]);

  return (
    <main className="App">
      <p>App has been running for {secondsElapsed} seconds.</p>
      <button onClick={resetTimer}>Reset</button>
    </main>
  );
}

export default App;

This surprises me, since it means that importing any part of userale will automatically start the logging process. I'd imagine we ought to figure out a way to have it not start by default when imported via NPM. Of course, this is a breaking change.

Page Load Stats

Received a user request for page load stats printed on logs. Will investigate.

Next.js Users having difficulty with UserALE.js

Next.js doesn't have a concept of 'window'. If embedded as a script-tag, UserALE.js attaches as an object to the window object. If embedded as an npm module, it will throw errors given that some of the listeners and properties UserALE.js references depend on window.

There are a few simple workarounds to this issue: https://frontend-digest.com/why-is-window-not-defined-in-nextjs-44daf7b4604e

Some users have difficult with solution three at the above link.

Would be good to find the best approach and include in documentation

Explore Local Storage SessionID Solution for Multi-Tab Applications

Currently, userale will assign a new sessionID unless it detects an existing sessionID stored in 'session storage". New pages (tabs) spawned from the current page is in the same browsing context, and will have access to sessionStorage. However, sessionStorage doesn't persist across different browsing contexts. Meaning if user opened new tab, then navigated to same application or page a new sessionID would be assigned. These two sessions should be the same (unless there is some edge case I'm not thinking about).

Option 1: pass sessionID into sessionStorage, detect new browser, push sessionStorage to localStorage, emit payload to grab (e.g., sessionID: $value), grab payload.

Option 2: Broadcast API (safari, IE support)?

Good article here, but dated: https://blog.guya.net/2015/06/12/sharing-sessionstorage-between-tabs-for-secure-multi-tab-authentication/#:~:text=Right%2C%20sessionStorage%20is%20not%20shared%20across%20tabs.&text=The%20way%20I%20solved%20it,have%20the%20sessionStorage%20for%20us.&text=Click%20to%20%E2%80%9CSet%20the%20sessionStorage,see%20the%20sessionStorage%20is%20shared.

Browser Extension builds Intervals Differently was: Poorly Written Intervals logs

We are seeing multiple interval logs written with same start, but different end-times and counts.

{
"@timestamp": "2018-03-23T20:31:39.542Z",
"count": 2,
"duration": 3818557,
"endTime": 1521837223993,
"host": "172.18.0.1",
"logType": "interval",
"major_ver": "1",
"minor_ver": "0",
"pageUrl": "https://confluence.draper.com/display/TF/Jedi+Slide+Decks+and+Write+Ups",
"patch_ver": "0",
"path": [
"div#main. aui-page-panel",
"div.ia-splitter",
"div#full-height-container",
"div#page",
"body#com-atlassian-confluence.theme-default aui-layout aui-theme-default synchrony-active",
"shadow",
"#document-fragment",
"html",
"#document",
"Window"
],
"sessionID": "session_1521544103912",
"startTime": 1521833405436,
"target": "div#main. aui-page-panel",
"targetChange": true,
"toolName": "test_app",
"toolVersion": null,
"type": "click",
"typeChange": true,
"userAction": false,
"userId": "bqw0205",
"useraleVersion": "1.0.0"
}

{
"@timestamp": "2018-03-23T20:31:39.542Z",
"count": 4,
"duration": 3819958,
"endTime": 1521837225394,
"host": "172.18.0.1",
"logType": "interval",
"major_ver": "1",
"minor_ver": "0",
"pageUrl": "https://confluence.draper.com/display/TF/Jedi+Slide+Decks+and+Write+Ups",
"patch_ver": "0",
"path": [
"div#main. aui-page-panel",
"div.ia-splitter",
"div#full-height-container",
"div#page",
"body#com-atlassian-confluence.theme-default aui-layout aui-theme-default synchrony-active",
"shadow",
"#document-fragment",
"html",
"#document",
"Window"
],
"sessionID": "session_1521544103912",
"startTime": 1521833405436,
"target": "div#main. aui-page-panel",
"targetChange": true,
"toolName": "test_app",
"toolVersion": null,
"type": "click",
"typeChange": true,
"userAction": false,
"userId": "bqw0205",
"useraleVersion": "1.0.0"
}

Distill related question

Is there any updated version of Distill? Documentation says it is not maintained. WHat is the alternative?

Decorator Syntax Examples

Users are interested in applying custom labels to their logs.

There is an easy way to do this through the .log .map and .packageCustomLog exports.

Some good examples in https://github.com/apache/incubator-flagon-useralejs/blob/master/example/index.js

We should probably start with a new .md to capture, contextualize, and decompose some of these examples.

Here's one:

document.addEventListener('click', function(e){ if (e.target.innerHTML === 'Click Me!') { **window.userale.map(function (log) { return Object.assign({}, log, { logType: 'custom', customLabel: 'map & packageLog Example' }); });** window.userale.packageLog(e, window.userale.details(window.userale.options(),e.type)); /**you'll want to reset the map callback function, or set a conditional (e.g., return log), else * the callback may be applied to other events of the same class (e.g., click) */ window.userale.map(); } else { return false } });

Here' another
`document.addEventListener('change', function(e) { if (e.target.value === 'packageCustomLog') { window.userale.packageCustomLog({ customLabel: 'packageCustomLog Example', customField1: 'foo', customField2: 'bar'}, function(){return {'foo': 'bar', 'bar': 'foo'}}, true ); } else { return false } });

Use UserALE.js example page as template for Cypress journey tests?

Still coming up to speed with Cypress, but if we're looking to mock user interactions with userale and flex it from an integration standpoint the example page (index.html) in ./example might be a good starting point. Index.js has all the features you'd want to flex userale's entire API (.options, .filter, .map, .log, .packageLog, .packageCustomLog). Worth investigating.

Refactor Gulp Build Pipeline

Gulp is reaching obsolescence. Might consider webpack or new npm functionality as an alternative.

Opinion: webpack may be a better option given that relying on newer npm functionality will affect the projects compatability with earlier versions of npm (12x-14x), which many of our users still rely on.

How to deploy in a a load balancing environment?

We have a proxy server that redirects user requests to different servers for load balancing. On the proxy side we have our UI application. Some analytics do need to track information about the host. That being said what is the advice wrt:

  1. Capturing every user interaction with the application would eventually end up creating large volume of data relatively quickly. Is there a way to purge data that is “x” days old or some retention/deletion policy of some sort?
  2. Is there a need to have a cluster of ELK or is one sufficient?

Explore OpenTelemetry User-Interaction Libraries

OpenTelemetry has a plugin that instruments the client for Click data, and attaches a traceID to logs. Should explore the project and see if we can't bring similar functionality into UserALE.js. OpenTelementry is ALv2.

Update README for 2.2.0

Update Readme text for 2.2.0 release. Update changelog entires. Add links to new examples.

Consider Cypress tests to flex wide range of .options API behavior

Recent bug reports show, for example, behavior where reducing the latency between log shipments, and trimming the package size results in dropping logs that should be sent before a page unloads. Currently, unit tests and integration tests dont flex the range of options like this (string options are easier to test for --logging endpoint, userId, sessionID, etc.). Would be good test test for these edge-cases.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.