sanchezzzhak / node-device-detector

Universal library for detecting devices based on the string `UserAgent`. We can identify: App, Bot, OS, Browser, Device brand, Device model and Device type (smartphone, phablet, tablet, desktop, TV and other types, total 13)
https://codesandbox.io/p/sandbox/demo-node-device-detect-forked-mwfx8e
138 stars 22 forks source link

Very slow + cpu intense #83

Closed MickL closed 1 year ago

MickL commented 2 years ago

The detection is very slow and cpu intense. For me it is not usable in production. The following I tested on a Macbook 16 M1 Pro with just the sample from the readme:

PostmanRuntime/7.29.0" -> 310ms

"Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36" -> 86ms

Code:

const DeviceDetector = require('node-device-detector');
const detector = new DeviceDetector;
const userAgent = 'Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36';

const t0 = Date.now();
detector.detect(userAgent);
const t1 = Date.now();
console.log(`Took '${t1-t0}ms'`)
sanchezzzhak commented 2 years ago

try to use

const detector = new DeviceDetector({deviceIndexes: true});

This will increase memory consumption, but reduce the time for known devices, this will also reduce the CPU load;

const createHash = (userAgent) => { return crypto.createHash('md5').update(String(userAgent)).digest('hex'); } const detect => async (userAgent) => { let result; let uaKey = 'UA:' + createHash(userAgent);

let cacheResult = await cache.get(uaKey);
if (cacheResult === void 0) {
  result = deviceDetector.detect(userAgent);
  await cache.set(uaKey, JSON.stringify(result), cache.TIME_15_MINUTE);
  return result;
} 

return JSON.parse(cacheResult); }


cache adaper for memcached
```js
const Memcached = require('memcached');

//Memcached.config.debug = true;

class Memcache {
  /** @typedef {Memcached} */
  #cache = null;

  static EVENT_FAILURE = 'failure';
  static EVENT_RECONNECTING = 'reconnecting';

  constructor() {
    this.TIME_MAX = 2592000;
    this.TIME_DAY = 86400;
    this.TIME_HOUR = 3600;
    this.TIME_15_MINUTE = 900;

    this.adapter = new Memcached(['127.0.0.1:11211']);
  }

  set adapter(adapter) {
    this.#cache = adapter;
  }

  get adapter() {
    return this.#cache;
  }

  /**
   * @param {String} key
   * @param {*} val
   * @param {Number} expTime
   * @returns {Promise<any>}
   */
  set(key, val, expTime = 3600) {
    return new Promise((resolve, reject) => {
      this.adapter.set(key, val, expTime, (err) => {
        if (err) {
          reject(err);
        } else {
          resolve(null);
        }
      });
    });
  }

  /**
   * @param {String} key
   * @returns {Promise<any>}
   */
  remove(key) {
    return new Promise((resolve, reject) => {
      this.adapter.del(key, (err, result) => {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      });
    });
  }

  /**
   * @param {String} key
   * @returns {Promise<any>}
   */
  get(key) {
    return new Promise((resolve, reject) => {
      this.adapter.get(key, (err, result) => {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      });
    });
  }

  on(event, callback) {
    this.adapter.on(event, callback);
    return this;
  }

  end() {
    this.adapter.end();
  }
}

module.exports = Memcache;
MickL commented 2 years ago

Using deviceIndexes: true does not change anything for me. The string Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36 always is about 84-86ms

sanchezzzhak commented 2 years ago

try this example

const detector = new DeviceDetector({deviceIndexes: true});

let lastCpuUsage;
const cpuUsage = () => {
  return lastCpuUsage = process.cpuUsage(lastCpuUsage);
}

const createTest = (testname, ua) => {
  console.time(testname);
  cpuUsage()
  detector.detect(ua);
  console.timeEnd(testname);
  console.log(testname, ua);
  console.log(testname, cpuUsage())
  console.log('------')
}

const userAgent1 = 'Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36';
const userAgent2 = 'Mozilla/5.0 (Linux; Android 8.0.0; ATU-L21) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36';
const userAgent3 = 'Mozilla/5.0 (Linux; Android 4.2.2; Trooper_X40 Build/Trooper_X40) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.114 Mobile Safari/537.36';
const userAgent4 = 'Mozilla/5.0 (Linux; U; Android 4.2.2; zh-CN; R831K Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/10.3.1.549 U3/0.8.0 Mobile Safari/534.30';

createTest('test1', userAgent1);
createTest('test2', userAgent1);
createTest('test3', userAgent2);
createTest('test4', userAgent3);
createTest('test5', userAgent4);
createTest('test6', userAgent1);

my result:

test1: 185.317ms
test1 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test1 { user: 215636, system: 444 }
------
test2: 79.819ms
test2 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test2 { user: 287318, system: 8210 }
------
test3: 63.22ms
test3 Mozilla/5.0 (Linux; Android 8.0.0; ATU-L21) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36
test3 { user: 350291, system: 8281 }
------
test4: 3.024ms
test4 Mozilla/5.0 (Linux; Android 4.2.2; Trooper_X40 Build/Trooper_X40) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.114 Mobile Safari/537.36
test4 { user: 353597, system: 8281 }
------
test5: 27.372ms
test5 Mozilla/5.0 (Linux; U; Android 4.2.2; zh-CN; R831K Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/10.3.1.549 U3/0.8.0 Mobile Safari/534.30
test5 { user: 381758, system: 8281 }
------
test6: 2.011ms
test6 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test6 { user: 383923, system: 8281 }
------

the first launch is very long, and it takes time for V8 to optimize the code, create a'test web server and periodically send requests.

offtop: I plan to make optimizations for the search of a web browser + desktop device (in process)

sanchezzzhak commented 2 years ago

preliminary optimizations to identify the client gave an increase of 38-50% :arrow_up:

Old tests

test1: 72.8ms
test1 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test1 { user: 510964, system: 19953 }
------
test2: 47.126ms
test2 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test2 { user: 550069, system: 27775 }
------
test3: 64.484ms
test3 Mozilla/5.0 (Linux; Android 8.0.0; ATU-L21) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36
test3 { user: 612832, system: 27775 }
------
test4: 2.503ms
test4 Mozilla/5.0 (Linux; Android 4.2.2; Trooper_X40 Build/Trooper_X40) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.114 Mobile Safari/537.36
test4 { user: 615422, system: 27775 }
------
test5: 26.792ms
test5 Mozilla/5.0 (Linux; U; Android 4.2.2; zh-CN; R831K Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/10.3.1.549 U3/0.8.0 Mobile Safari/534.30
test5 { user: 642282, system: 27831 }
------
test6: 1.473ms
test6 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test6 { user: 643962, system: 27831 }
------
test7: 463.938ms
test7 Mozilla/5.0 ArchLinux (X11; U; Linux x86_64; en-US) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.100 Safari/534.30
test7 { user: 1108373, system: 27958 }
------
test8: 4.503ms
test8 Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
test8 { user: 1114473, system: 32131 }
------
test9: 239.04ms
test9 Mozilla/5.0 ArchLinux (X11; U; Linux x86_64; en-US) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.100 Safari/534.30
test9 { user: 1345244, system: 40100 }
------
test10: 5.54ms
test10 Mozilla/5.0 ArchLinux (X11; U; Linux x86_64; en-US) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.100 Safari/534.30
test10 { user: 1350913, system: 40100 }
------

banchmarks.js

Test: Mozilla/5.0 (Linux; Android 5.0; NX505J Build/KVT49L) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.78 Mobile Safari/537.36
# detect only device
device DisableIndexes x 236 ops/sec ±90.66% (87 runs sampled)
device EnableIndexes x 10,727 ops/sec ±0.37% (93 runs sampled)

# detect only client
client EnableIndexes x 1,545 ops/sec ±1.04% (89 runs sampled)
client DisableIndexes x 718 ops/sec ±1.60% (85 runs sampled)
petehanson commented 2 years ago

Hi, I'm trying out the library in a lambda environment and I see typical detection taking around 600 ms, with some going upwards of 3-4 seconds (I'm using time/timeEnd around the detection code) to identify os, client, device, and bot. I'm not using clientHints. Would that help speed this up some? Any other ideas? It's a lambda with 384M of RAM. This is on version 2.0.4.

sanchezzzhak commented 2 years ago

hi, show me the code how you use the library.

petehanson commented 2 years ago

here's the function I'm running:

decorateWithUserAgentData(userAgent) {

    const returnData = {
        os: null,
        client: null,
        device: null,
        bot: null
    };

    if (typeof userAgent === "string") {

        const detector = new DeviceDetector({
            clientIndexes: true,
            deviceIndexes: true,
            deviceAliasCode: false,
        });

        const result = detector.detect(userAgent);
        const bot = detector.parseBot(userAgent);

        if ('os' in result && Object.keys(result['os']).length > 0) {
            returnData['os'] = result['os'];
        }

        if ('client' in result && Object.keys(result['client']).length > 0) {
            returnData['client'] = result['client'];
        }

        if ('device' in result && Object.keys(result['device']).length > 0 && _.get(result, 'device.id', '') !== '') {
            returnData['device'] = result['device'];
        }

        if (Object.keys(bot).length > 0) {
            returnData['bot'] = bot
        }

    }

    return returnData;
}

Thanks for the help and taking a look at this! It's a method that adds elements to a bunch of other clickstream data.

petehanson commented 2 years ago

I did try a version of the cache approach above like so:

decorateWithUserAgentData(userAgent) {

    const returnData = {
        os: null,
        client: null,
        device: null,
        bot: null
    };

    if (typeof userAgent === "string") {

        const key = StringUtils.md5(userAgent);

        if (key in map) {
            const record = map[key];
            return record;
        } 

        const detector = new DeviceDetector({
            clientIndexes: true,
            deviceIndexes: true,
            deviceAliasCode: false,
        });

        const result = detector.detect(userAgent);
        const bot = detector.parseBot(userAgent);

        if ('os' in result && Object.keys(result['os']).length > 0) {
            returnData['os'] = result['os'];
        }

        if ('client' in result && Object.keys(result['client']).length > 0) {
            returnData['client'] = result['client'];
        }

        if ('device' in result && Object.keys(result['device']).length > 0 && _.get(result, 'device.id', '') !== '') {
            returnData['device'] = result['device'];
        }

        if (Object.keys(bot).length > 0) {
            returnData['bot'] = bot
        }

    }

    return returnData;
}

I pulled a couple of weeks of user agent data and then counted each one and generated a hash of results from each string that covers 95% of agents. I did decrease execution time quite a bit, but it's not a perfect solution to this. Though the slight speed penalty on 5% of the traffic may be ok here.

sanchezzzhak commented 2 years ago

https://docs.aws.amazon.com/lambda/latest/operatorguide/global-scope.html I have no idea how lambda works and what happens after the function is executed with objects(

Object creation new DeviceDetector must be called once. The fact is that when we create an object, we load data from disk every time.

1

const detector = new DeviceDetector({
            clientIndexes: true,
            deviceIndexes: true,
            deviceAliasCode: false,
        });    

decorateWithUserAgentData(userAgent) {

    // ...
}

2

let detector;
decorateWithUserAgentData(userAgent) {
   if (detector === void 0) {
        detector  = new DeviceDetector({
            clientIndexes: true,
            deviceIndexes: true,
            deviceAliasCode: false,
        });
    }
    // ...
}

3

decorateWithUserAgentData(userAgent) {
   if (this.detector === void 0) {
        this.detector = new DeviceDetector({
            clientIndexes: true,
            deviceIndexes: true,
            deviceAliasCode: false,
        });
    }
    // ...
}

try one of the three examples

petehanson commented 2 years ago

Ah, I see, so the constructor has a high cost to initially run. Yeah, if you did the initialization in the global scope of a lambda, that would be available for subsequent invocations while the function remains warm. The downside there is that each time a new concurrent request happens and lambda cold starts another instance of the function, it would be a new invocation of the constructor.

I can try moving the constructor to the global space and see how that works. My work load is somewhat predictable, so cold starts aren't too much of an issue.

Thanks for taking a look at this!

sanchezzzhak commented 1 year ago

the current performance has improved, if you have any ideas on how to improve more, you can speak out.