CérénIT

Le blog tech de Nicolas Steinmetz (Time Series, IoT, Web, Ops, Data)

Ma solution pour le Warp 10 Code Contest - partie 1

timeserieswarp10geospatialchallenge

La société SenX a proposé un code contest suite à la publication de son article sur les formes géospatiales. L'objet du concours porte sur le trajet d'un véhicule aux USA et il consiste à déterminer :

  • la distance réalisée sur la fameuse route 66 durant ce trajet,
  • de déterminer les émissions de CO2 réalisées durant ce trajet sur la route 66.

Maintenant que le gagnant a été annoncé (TL;DR: moi 😎🎉) et en attendant le corrigé officiel, voici ma proposition de solution.

Distance parcourue sur la route 66

Les données de départ sont :

  • @senx/dataset/route66_vehicle_gts : le trajet réalisé par le véhicule
  • @senx/dataset/route66_geoshape : la route 66
// Define points from the car journey on the US66 road
[
  // Here is the gts of the car datalogger
  @senx/dataset/route66_vehicle_gts

  // Here is the route 66 geoshape (+/- 20meters)
  @senx/dataset/route66_geoshape
  mapper.geo.within 0 0 0
] MAP
"onTheRoad" STORE

$onTheRoad
{
 'timesplit' 60 s
}
MOTIONSPLIT
0 GET
'sectionOnTheRoad' STORE

// Compute distance for each GTS and output it as a single point
[ $sectionOnTheRoad mapper.hdist MAXLONG MAXLONG 1 ] MAP
// Sum all GTS
0 SWAP <% VALUES 0 GET + %> FOREACH
// Convert to km
1000 /
// Enjoy !

Explications :

  • Le premier bloc utilise le mapper mapper.geo.within (doc). Ce mapper compare deux zones géographiques et ne retient que les poits qui sont dans la zone voulue. Ici, je prends donc tous les points du trajet et les compare avec ceux de la route 66. Seuls les points sur la route 66 sont conservés. Le résultat est une aggrégation de points que l'on stocke dans la variable onTheRoad.
  • Pour le second bloc : dans le studio, lorsque l'on regarde la liste des points obtenus dans l'onglet "Tabular view", on peut voir que les points sont espacés en général de minimum 10 secondes et jusqu'à une minute environ. Après avoir relu le billet "Use motion to automatically split GTS", j'ai retenu ce seuil d'une minute et la fonction MOTIONSPLIT (doc) pour calculer la distance entre deux points. Obtenant une liste de 1 élément contenant une liste, j'ai rajouté le 0 GET pour supprimer la liste parente. On obtient alors une liste de 8 séries temporelles (GTS) correspondant à chaque tronçon sur la route. On stocke cela dans la variable sectionOnTheRoad.

warp10 - section on the road

  • Pour le dernier bloc - partie 1 : mapper.hdist (doc) permet de calculer la distance totale sur une fenêtre glissante de points. L'utilisation de MAXLONG permet d'avoir une valeur suffisamment grande pour notre cas d'espèce pour prendre l'ensemble des données de chacune des 8 listes - il n'est pas nécessaire de connaitre la taille exacte de la liste pour travailler dessus et cela ne crée pas d'erreur non plus ; ça peut déstabiliser !. Le 1 permet de n'avoir qu'une valeur en sortie. On a donc en sortie la distance de chacune des 8 sections.

warp10 - total distance of each section

  • Pour le dernier bloc - partie 2 : là, j'avoue la syntaxe est un peu cryptique 🤯. L'idée est donc de faire la somme de toutes les distances totales obtenues précédemment. Il faut donc faire 0 (pour initialiser l'opération d'addition) et ajouter la première valeur de la liste et ainsi de suite. Une fois qu'on a la somme, on divise par 1000 pour avoir des kilomètres
  • La réponse est alors: 79.82147744769853

Pour comprendre la partie 2, on peut réécrire la chose de la façon suivante :

[ $sectionOnTheRoad mapper.hdist MAXLONG MAXLONG 1 ] MAP
'totalDistancePerSection' STORE

0 $totalDistancePerSection <% VALUES 0 GET + %> FOREACH

Non, toujours pas ? Vous me rassurez, j'ai du creuser plus loin aussi.

Commençons par :

$totalDistancePerSection <% VALUES 0 GET %> FOREACH

VALUES (doc) consomme une série temporelle et en sort les valeurs sous la forme d'une liste. Nous avons une liste initiale de 8 séries que nous avons ramené à 8 points. Avec FOREACH (doc), on applique donc la fonction VALUES sur chaque série contenant un seul point. Plutôt que d'avoir en sortie des listes à un seul point, le 0 GET permet d'avoir directement la valeur.

warp10 - values et foreach

Pour faire une addition, en WarpScript, c'est :

1 1 +

ou :

1
1
+

Par celà, j'entends que pour appliquer +, il faut que les deux éléments soient définis dans la pile.

Notre boucle FOREACH emet dans la pile chaque valeur qu'il faut ajouter à la précédente. On peut donc rajouter le + dans la boucle FOREACH :

$totalDistancePerSection <% VALUES 0 GET + %> FOREACH

Mais si je cherche à exécuter cela, cela ne fonctionne pas - cela reviendrait à faire:

valeur1IssueDuForeach +
valeur2IssueDuForeach +
valeur3IssueDuForeach +
valeur4IssueDuForeach +
...

Si on part de la fin, la valeur 4 va pouvoir être additionnée à la valeur 3 car celle-ci existe dans la pile. MAIS la valeur 1 n'est additionnée à rien à ce stade et l'opération est invalide. D'où la nécessité de rajouter le 0 pour pouvoir avoir deux éléments pour notre première addition.

Ce qui nous donne bien :

0 $totalDistancePerSection <% VALUES 0 GET + %> FOREACH

Maintenant que la brume s'est éclaircie et que le 🤯 est passé à 😎 pour cette syntaxe de fin, je vous propose de nous retrouver dans un prochain billet pour la suite de ma solution au concours.

Ma comptabilité, une série temporelle comme les autres - partie 4 - dashboards

warp10timeseriescomptabilitédiscoverydashboard

Suite de notre épopée :

Nous allons voir aujourd'hui comment présenter ces données à l'aide de Discovery, la solution de Dashboard as Code pour Warp 10 fournie par SenX.

Installation de Discovery

Tout est décrit dans le billet Truly Dynamic Dashboards as Code

Dans mon cas, warp 10 est dans une partition dédiée /srv/warp10 - warp 10 est donc installé dans /srv/warp10/warp10. C'est la valeur de $WARP10_HOME.

Pour la configuration du plugin HTTP, j'ai un fichier $WARP10_HOME/etc/conf.d/80-discovery.conf contenant :

# Load the HTTP Plugin
warp10.plugin.http = io.warp10.plugins.http.HTTPWarp10Plugin
# Define the directory where endpoint spec files will reside
http.dir = /srv/warp10/discovery
# Define the host and port the plugin should bind to
http.host = 127.0.0.1
http.port = 8081
# Expose the Directory and Store so FETCH requests can be performed via the plugin
egress.clients.expose = true

Le plugin HTTP sera donc accessible via une url de base en http://127.0.0.1:8081/

J'ai ensuite créé le fichier /srv/warp10/discovery/discovery.mc2/srv/warp10/discovery est la valeur associée à http.dir dans le fichier précédent.

{
  'path' '/discovery/'
  'prefix' true
  'parsePayload' true
  'macro' <%
    'cerenit/dashboards/' @senx/discovery/dispatcher
  %>
}

Ce fichier indique que :

  • Les dashboards seront disponibles à partir de l'url /discovery/<nom_du_dashboard> ou /discovery/<dossier_ou_arborescence>/<nom_du_dashboard>
  • Le dossier où seront les dashboards seront stockés dans $WARP10_HOME/macros/cerenit/dashboards. Il s'agira de fichier WarpScript ou Flows avec l'extension en .mc2.

Avec ces deux fichiers, nous savons maintenant que :

  • nous accèderons aux dashboards via http://127.0.0.1:8081/discovery/<nom_du_dashboard>.
  • les dashboards seront des fichiers stockés dans ``$WARP10_HOME/macros/cerenit/dashboards`
  • donc le dashboard $WARP10_HOME/macros/cerenit/dashboards/mon_dashboard.mc2 sera accessible via http://127.0.0.1:8081/discovery/mon_dashboard.

Création du premier dashboard

Un dashboard se décompose en différentes parties. Celle contenant les données a le mot clé tiles et contient différente tile. Chaque tile affiche un graphique, un zone de texte, un titre ou tout composant warpView. Pour le reste, on s'appuiera sur le template par défaut.

Donc créeons un fichier $WARP10_HOME/macros/cerenit/dashboards/comptabilite/compta1.mc2 contenant :

<%
   {
     'tiles' [
       {
         'type' 'display'
         'w' 4 'h' 1 'x' 3 'y' 0
         'data' 'Compta - Exemple 1'
       }
       {
         'type' 'line'
         'w' 4 'h' 2 'x' 1 'y' 3
         'data' [
           @cerenit/accountancy/revenue
           'revenue' STORE
           $revenue
         ]
       }
       {
         'type' 'line'
         'w' 4 'h' 2 'x' 5 'y' 3
         'data' [
           @cerenit/accountancy/expense
           'expense' STORE
           $expense
         ]
       }
       {
         'type' 'line'
         'w' 4 'h' 2 'x' 3 'y' 5
         'data' [
           $revenue $expense -
         ]
       }
     ]
   }
   @senx/discovery/render
%>

Comme indiqué précédemment, je me focalise sur le contenu de tiles. La grille de présentation des dashboards est fixé à 12 colonnes par défaut.

Ici, je cherche donc à afficher 4 éléments :

  • Le premier élément est un bloc titre, contenant "Compta - Exemple 1". Il est placé sur la 3ème item de la grille en parant de la gauche (valeur de x), il est en haut (valeur y de 0) et il occupe une largeur de 4 (valeur de w) et une hauteur de 1 (valeur de h),
  • les trois éléments suivants sont des graphiques de tyme "line" avec un positionnement pour deux les deux premiers soient sur une ligne et le dernier en dessous et plutôt centré par rapport aux deux premmiers.
  • le deuxième et troisième éléments font appels à des macros @cerenit/accountancy/xxx. Je pourrais mettre du code Warpscript directement dans le fichier comme dans l'exemple. Toutefois, le code exécuté dans le dashboard est visible dans le navigateur. Dans la mesure où mes requêtes pour récupérer les données demandent de l'authentification avec un passage de token, je déporte ce code dans une macro et je ne fais donc qu'appeler cette macro. Ainsi, le code sera généré coté serveur et seul le résultat sera retourné dans le navigateur.
  • le dernier élement illustre la capacité intéressante et différentiante de Discovery : on ne fait pas que décrire le dashboard avec du code, on peut aussi faire des opérations sur nos données. Ici je calcule dynamiquement le résultat à partir des données de chiffre d'affaires (revenue) et de dépenses (expense). J'aurais pu faire l'appel à une troisième macro comme les deux éléments précédents, mais vu que j'ai cette capacité de réaliser des opérations au sein d'un dashboard, pourquoi me priver ?
  • enfin, on appelle la fonction @senx/discovery/render pour générer le dashboard.

Revenons sur nos macros ; Warp 10 permet d'avoir des macros exécutées coté serveur. Ces macros peuvent être utiles pour créer/partager du code, elles peuvent prendre des paramètres en entrée si besoin et elles sont exécutées coté serveur. Dans notre cas, pour éviter que nos tokens se balladent dans le navigateur comme indiqué précédemment, c'est cette propriété qui va nous intéresser.

La macro @cerenit/accountancy/revenue se trouve donc dans le fichier $WARP10_HOME/macros/cerenit/accountancy/revenue.mc2 et contient :

<%
  {
    'name' 'cerenit/accountancy/revenue'
    'desc' 'Provide revenue'
  } INFO

  // Actual code
  SAVE 'context' STORE

  '<readToken>' 'readToken' STORE
  [ $readToken 'revenue' { 'company' '=cerenit' } NOW [ 2016 12 1 ] TSELEMENTS-> ] FETCH
  0 GET

  $context RESTORE
%>
'macro' STORE
$macro

Je ne vais pas m'étendre sur la rédaction des macros mais succintement :

  • Le permier bloc donne le nom de la macro et sa description
  • La suite indique le code qui va être réalisé - pour ma part, un FETCH sur la classe "revenue" pour la compagny "cerenit" du 01/12/2016 jusqu'à maintenant. Cette liste n'a qu'un élément que je récupère. C'est ce qui me sera retourné.

La macro @cerenit/accountancy/expense est sur le même modèle en remplaçant revenue par expense.

Ces deux macros nous retournent donc chacune une série temporelle sur la période 12/2016 jusqu'à ce jour : une pour le chiffre d'affaires, une pour les dépenses.

Si vous allez sur http://127.0.0.1:8081/discovery/comptabilite/compta1, vous verrez le dashboard suivant :

warp10

Le template par défaut est assez minimaliste et on note la présence d'un logo SenX. Je n'ai rien contre, mais comme c'est la compatabilité de mon entreprise que je présente, autant changer cet aspect des choses.

Ajustements graphiques

Pour continuer progressivement, nous allons :

  • Rajouter un titre et une description à notre dashboard en précisant les propriétés title et description en début de fichier
  • Rajouter un footer en bas de page en précisant la propriété footer
  • Supprimer le logo SenX en précisant la propriété template
  • Ajuster la position des blocs pour qu'ils soient centrés comme le reste des éléments

On met cela dans un nouveau fichier $WARP10_HOME/macros/cerenit/dashboards/comptabilite/compta2.mc2.

<%
   {
     'title' 'Comptabilité CerenIT'
     'description' 'Comptabilité CérénIT depuis 2016'
     'tiles' [
       {
         'type' 'display'
         'w' 4 'h' 1 'x' 4 'y' 0
         'data' 'Compta - Exemple 2'
       }
       {
         'title" "Chiffre d'affaires'
         'type' 'line'
         'w' 4 'h' 2 'x' 2 'y' 3
         'data' [
           @cerenit/accountancy/revenue
           'revenue' STORE
           $revenue
         ]
       }
       {
         'title' 'Dépenses'
         'type' 'line'
         'w' 4 'h' 2 'x' 6 'y' 3
         'data' [
           @cerenit/accountancy/expense
           'expense' STORE
           $expense
         ]
       }
       {
         'title' 'Résultat'
         'type' 'line'
         'w' 4 'h' 2 'x' 4 'y' 5
         'data' [
           $revenue $expense -
         ]
       }
     ]
     'footer' '<p style="text-align: center;">CérénIT &copy; 2021 - Réalisé avec Discovery et Warp 10 de SenX</p>'
     'template'
   <'
<!DOCTYPE html><html><head><title id="pageTitle"></title>
  {{{CSS}}}
  {{{HEAD}}}
</head>
<body>
<div class="heading">
  <div class="header"><h1 id="title" class="discovery-title"></h1><p id="desc" class="discovery-description"></p></div>
</div>
{{{HEADER}}}
{{{GRID}}}
{{{FOOTER}}}
{{{JS}}}
</body></html>
    '>
   }
   @senx/discovery/render
%>

Si les propriétés title, description et footer vont de soi, pour trouver comment supprimer le logo SenX, il m'a fallu lire le contenu de la macro @senx/discovery/html pour mieux comprendre les différents placehoders et leur fonctionnement.

Si vous allez sur http://127.0.0.1:8081/discovery/comptabilite/compta2, vous verrez le dashboard suivant :

warp10

A ce stade, on note que les propriétés title de chaque graphique n'est pas affiché. En dehors de ça, nous retrouvons bien tous nos éléments ajustés.

Néanmoins, cette lecture de @senx/discovery/html permet de voir que l'on a pas mal de points d'entrée pour rajouter des éléments spécifiques. Le tout sera de veiller à ne pas impacter les composants graphiques WarpView dans leur sémantique pour ne pas créer de dysfonctionnement.

Un petit coup de bar(re)

Pour finir ce tutoriel, nous allons :

  • lisser un peu ces lignes d'une part, en remplaçant le type de line à spline pour les trois graphiques déjà réalisés (pour les autres modes de réprésentation, voir les options de chart)
  • rajouter un histogramme avec le cumul annuel de nos données avec un chart de type bar.

On met cela dans un nouveau fichier $WARP10_HOME/macros/cerenit/dashboards/comptabilite/compta3.mc2.

<%
   {
     'title' 'Comptabilité CerenIT'
     'description' 'Comptabilité CérénIT depuis 2016'
     'tiles' [
       {
         'type' 'display'
         'w' 4 'h' 1 'x' 4 'y' 0
         'data' 'Compta - Exemple 3'
       }
       {
         'title' 'Chiffre d\'affaires'
         'type' 'spline'
         'w' 4 'h' 2 'x' 2 'y' 3
         'data' [
           @cerenit/accountancy/revenue
           'revenue' STORE
           $revenue
         ]
       }
       {
         'title' 'Dépenses'
         'type' 'spline'
         'w' 4 'h' 2 'x' 6 'y' 3
         'data' [
           @cerenit/accountancy/expense
           'expense' STORE
           $expense
         ]
       }
       {
         'title' 'Résultat'
         'type' 'spline'
         'w' 4 'h' 2 'x' 4 'y' 5
         'data' [
           $revenue $expense -
         ]
       }
       {
          'title' 'Consolidation annuelle'
          'type' 'bar'
          'w' 4 'h' 2 'x' 4 'y' 7
          'data' [
            [ $revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
            [ $expense bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
            [ @cerenit/accountancy/result bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
          ]
          'options' { 'timeMode' 'timestamp' }
       }
     ]
     'footer' '<p style="text-align: center;">CérénIT &copy; 2021 - Réalisé avec Discovery et Warp 10 de SenX</p>'
     'template'
   <'
<!DOCTYPE html><html><head><title id="pageTitle"></title>
  {{{CSS}}}
  {{{HEAD}}}
</head>
<body>
<div class="heading">
  <div class="header"><h1 id="title" class="discovery-title"></h1><p id="desc" class="discovery-description"></p></div>
</div>
{{{HEADER}}}
{{{GRID}}}
{{{FOOTER}}}
{{{JS}}}
</body></html>
    '>
   }
   @senx/discovery/render
%>

Pour ce dernier graphique, il est donc de type bar. Pour le détail des requêtes, je vous renvoie à la partie 2 qui explique cela. Dans notre cas, il faut juste veiller à passer une option supplémentaires pour que timeMode interprête la date issue de la requête comme un timestamp et non comme une date par défaut. D'autres options comme la gestion de la présentation en mode vertical/horizontal ou en mode "stacked" ou pas.

Si vous allez sur http://127.0.0.1:8081/discovery/comptabilite/compta3, vous verrez le dashboard suivant :

warp10

Pour résumer ce billet, nous aovns pu voir que :

  • Warp 10 dispose d'une solution de Dashboard avec Discovery
  • Comme on pouvait le voir dans le studio, à partir du moment où l'on a nos requêtes, il est aisé de les mettre dans des dashboards
  • Les macros permettent de ne pas exposer des tokens dans le navigateur et de faire des calculs cotés serveur
  • Il est possible de personnaliser l'apparence des dashboards - des points d'entrée et des mécanismes de surcharges sont à notre disposition pour en faire ce que l'on souhaite
  • Les composants WarpView sont assez riches et se configurent facilement

Ma comptabilité, une série temporelle comme les autres - partie 3 - exécution distante

warp10timeseriescomptabilitéremote executionrexeclmaptemplate

Suite de notre épopée :

A l'issue du précédent billet, depuis le WarpStudio et en stockant les données dans la sandbox, nous avons manipuler les données pour :

  • refaire les précisions de juin à décembre 2020 à partir des données de 01/2017 à 05/2017
  • comparer ses prévisions avec les résultats réels
  • faire les prévisions pour 2021.

Cependant, cela n'est pas parfait :

  • La durée de vie des données dans la sandbox est limitée à 2 semaines
  • Il faut copier/coller ses requêtes dans le studio pour obtenir les graphiques
  • Mais cela permet d'évaluer les extensions commerciales déployées sur la sandbox mais que vous n'avez pas forcément sur votre instance Warp 10 (au moins pour le moment)

Nous allons voir aujourd'hui comment rappatrier les données générées dans sa propre instance Warp 10.

Génération des prévisions

Pour commencer du bon pied et être sur que tout le monde est au même niveau, nous allons regénérer les prévisions et chaque prévision sera alors stockée dans une sériée dédiée. Cela nous permet d'avoir notre jeu de données de départ. On doit pouvoir sauver directement nos données dans une base distante, mais pour simplifier le tutoriel, nous allons le faire en deux étapes.

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des séries 2017 > 2020
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

// Prévision sur les 12 prochains mois avec AUTO
// On n'a pas besoin d'afficher les données
// Donc plus besoin de les stocker sous forme de variable avec utilisation de la fonction STORE
// Par contre, on veut perstister les données en base
// ce qui se fait avec UPDATE et l'utilsation d'un token en écriture
[ $result mapper.todouble 0 0 0 ] MAP
AUTO 12 FORECAST.ADDVALUES
"auto_result" RENAME
$writeToken UPDATE

[ $revenue mapper.todouble 0 0 0 ] MAP
AUTO 12 FORECAST.ADDVALUES
"auto_revenue" RENAME
$writeToken UPDATE

[ $exp mapper.todouble 0 0 0 ] MAP
AUTO 12 FORECAST.ADDVALUES
"auto_expense" RENAME
$writeToken UPDATE

# Prévisions avec SAUTO
[ $result mapper.todouble 0 0 0 ] MAP
12 SAUTO 12 FORECAST.ADDVALUES
"sauto_result" RENAME
$writeToken UPDATE

[ $revenue mapper.todouble 0 0 0 ] MAP
12 SAUTO 12 FORECAST.ADDVALUES
"sauto_revenue" RENAME
$writeToken UPDATE

[ $exp mapper.todouble 0 0 0 ] MAP
12 SAUTO 12 FORECAST.ADDVALUES
"sauto_expense" RENAME
$writeToken UPDATE

Si nous vouulons vérifier que vos données sont bien en base, il faut utiliser FETCH :

// Ex de FETCH avec la série auto_revenue
'<read token>' 'readToken' STORE
[ $readToken 'auto_revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2022-01-01T00:00:00Z' ] FETCH
0 GET

Attaquons maintenant la grande traversée des données vers mon instance Warp 10.

Activation de l'exécution à distance

Alors on pourrait simplement migrer les données à coup de "curl in/curl out" mais l'idée ici est plus d'illustrer les interactions possibles entre des instances Warp 10.

Pour exécuter du warpscrip sur une instance distante, il faut utiliser la fonction REXEC

Cette exécution distante est désactivée par défaut, il faut donc activer cette extension:

Dans /path/to/warp10/etc/conf.d/70--extensions.conf, nous avons :

// REXEC
#warpscript.extension.rexec = io.warp10.script.ext.rexec.RexecWarpScriptExtension
#warpscript.rexec.endpoint.patterns = .*
// REXEC connect timeout in ms (default = 0, no timeout)
#warpscript.rexec.timeout.connect = 3600000
// REXEC read timeout in ms (default = 0, no timeout)
#warpscript.rexec.timeout.read = 3600000

Décommentons ces lignes et relançons warp 10.

Pour valider que REXEC est bien activé, depuis le studio, nous pouvons faire un test simple:

'2 2 +' 'https://warp10.url:port/api/v0/exec' REXEC

La réponse est 4.

Attention, il faut choisir votre instance comme endpoint dans la liste déroulante du studio. Si vous êtes sur le endpoint de la sandbox, vous auez le message d'erreur suivant :

https://sandbox.senx.io/api/v0/exec
line #2: Exception at ''2%202%20+' 'https://warp10.url:port/api/v0/exec' =>REXEC<=' in section [TOP] (REXEC encountered a forbidden URL 'http://warp10.url:port/api/v0/exec')

En effet, depuis la Sandbox, il n'est pas possible d'accéder à n'importe quelle machine par mesure de sécurité.

Requêtage simple à distance

Dans le studio, à partir de maintenant, il doit être configuré pour utliiser votre instance Warp 10 comme endpoint.

Le test simple étant fonctionnel, passons à un test un peu plus compliqué, à savoir recupérer nos données hébergées depuis la sandbox en lecture pour le moment.

// tokens pour la sandbox
'<sandboxReadToken>' 'sandboxReadToken' STORE
'<sandboxWriteToken>' 'sandoxWriteToken' STORE

// Url de la sandbox
'https://sandbox.senx.io/api/v0/exec' 'url' STORE

// On introduit ici la notion de template - comme on va vouloir récupérer plusieurs séries avec les mêmes paramètres
// Autant automatiser un peu et s'appuyer sur une boucle ! :-)
// On crée donc un TEMPLATE pour la fonction FETCH qui va récupérer un token en écriture
// et un nom de classe permettant de récupérer nos GTS.
// Rappel le <' ... '> permet de faire des strings en multi-lignes
// On stocke le template sous la forme d'une variable fetchTpl.
<'
  {
    'token' '{{ remoteReadToken }}'
    'class' '{{ remoteClass }}'
    'labels' {}
    'start' '2016-12-01T00:00:00Z'
    'end' '2022-01-31T00:00:00Z'
  } FETCH
'>
'fetchTpl' STORE

// Avec la fonction TEMPLATE, on remplace les clés par leurs valeurs en fournissant le template
// et un dictionnaire à la fonction.
$fetchTpl
{ 'remoteReadToken' $sandboxReadToken 'remoteClass' 'revenue' } TEMPLATE

// Execution de la requête distante avec REXECZ
// La différence avec REXEC est qu'une compression est appliquée sur la réponse à la requête
$url REXECZ
// La liste de GTS issue de FETCH ne contient qu'une liste, on prend donc la première
0 GET
// Stockage sous la forme d'une variable
'revenueGTS' STORE
// Affichage de la série
$revenueGTS

Si vous allez dans l'onglet dataviz, vous pouvez constater que vos données issues de la sandbox mais qui ont transité via votre instance sont bien disponibles.

Transfert des données - 1 série

Si nous commençons par une seule série :

// tokens de l'instance
'<instanceReadToken>' 'instanceReadToken' STORE
'<instanceWriteToken>' 'instanceWriteToken' STORE

// tokens pour la sandbox
'<sandboxReadToken>' 'sandboxReadToken' STORE
'<sandboxWriteToken>' 'sandoxWriteToken' STORE

// Url de la sandbox
'https://sandbox.senx.io/api/v0/exec' 'url' STORE

// Template de code warpscript
<'
  {
    'token' '{{ remoteReadToken }}'
    'class' '{{ remoteClass }}'
    'labels' {}
    'start' '2016-12-01T00:00:00Z'
    'end' '2022-01-31T00:00:00Z'
  } FETCH
'>
'fetchTpl' STORE

// Substitution des variables
$fetchTpl
{ 'remoteReadToken' $sandboxReadToken 'remoteClass' 'revenue' } TEMPLATE
// Exécution de la requête
$url REXECZ
// La liste de GTS issue de FETCH ne contient qu'une liste, on prend donc la première
0 GET
// Il faut renommer "localement" la série avant de pouvoir la stocker dans l'instance
// Peut éviter de mauvaises manipulations que l'on pourrait regretter :-)
"revenue" RENAME
// Persistance des données
$instanceWriteToken UPDATE

Il y a quelques occurences de "revenue" en dur dans le code, il va falloir améliorer cela.

Transfert des données - plusieurs séries

Et maintenant, traitons nos 9 series d'un coup

// tokens de l'instance
'<instanceReadToken>' 'instanceReadToken' STORE
'<instanceWriteToken>' 'instanceWriteToken' STORE

// tokens pour la sandbox
'<sandboxReadToken>' 'sandboxReadToken' STORE
'<sandboxWriteToken>' 'sandoxWriteToken' STORE

// Url de la sandbox
'https://sandbox.senx.io/api/v0/exec' 'url' STORE

// Template de code warpscript
<'
  {
    'token' '{{ remoteReadToken }}'
    'class' '{{ remoteClass }}'
    'labels' {}
    'start' '2016-12-01T00:00:00Z'
    'end' '2022-01-31T00:00:00Z'
  } FETCH
'>
'fetchTpl' STORE

// Création d'une liste avec nos 9 séries
// C'est cette liste que nous allons passer ensuite dans une MACRO.
// Cette MACRO va être exécutée sur chaque élément de la liste via l'utilisation de la fonction LMAP
// https://www.warp10.io/doc/LMAP
[ 'revenue' 'exp' 'result' 'auto_revenue' 'auto_result' 'auto_expense' 'sauto_revenue' 'sauto_result' 'sauto_expense' ]
// Début de la MACRO
<%
  // On récupère la valeur de la liste que l'on stocke sous la forme d'une variable
  'remoteClass' STORE

  // Substitution des valeurs de template
  $fetchTpl
  { 'remoteReadToken' $sandboxReadToken 'remoteClass' $remoteClass } TEMPLATE

  // Exécution distante de la requête
  $url REXECZ
  // On récupère ici une liste de GTS - plutôt que d'en extraire la GTS comme précédemment
  // on va garder une liste de GTS à 1 élément, mais ce qui permet à nouveau d'utiliser la fonction LMAP
  // Sur chaque entrée de la liste, une seconde macro est appliquée
  // Le contenu de notre macro consiste à utliser la fonction RENAME
  // '+' RENAME, cela revient à renommer la GTS en prenant le même nom que celui qui est fourni
  // '+x' RENAME aurait ajouté un x au nom de la série
  // Il reste l'index de la liste à traiter - soit on le supprime avec DROP
  //<% DROP '+' RENAME %> LMAP
  // Soit on passe F comme 3ème argument à LMAP - cela permet d'ignorer cet index
  // <% '+' RENAME %> F LMAP
  // Prenons la seconde forme :
  <% '+' RENAME %> F LMAP
  // Toutes nos séries ont été correctement renommées !
  // On persiste la GTS dans la base locale
  $instanceWriteToken UPDATE
%>
// Fin de la MACRO
// Application de la fonction LMAP pour que notre macro soit exécutée sur chaque élément de la liste.
// Comme on ne veut que les valeurs de la liste et pas les index, on positionne aussi F
// comme 3ème argument à LMAP
F LMAP

Et voilà, nos données ont été récupérées de la Sandbox et stockées dans notre instance locale.

Bonus

Une version alternative - dans mes données, je peux tricher et ne filtrer que sur le label company avec pour valeur cerenit:

// tokens de l'instance
'<instanceReadToken>' 'instanceReadToken' STORE
'<instanceWriteToken>' 'instanceWriteToken' STORE

// tokens pour la sandbox
'<sandboxReadToken>' 'sandboxReadToken' STORE
'<sandboxWriteToken>' 'sandoxWriteToken' STORE

// Url de la sandbox
'https://sandbox.senx.io/api/v0/exec' 'url' STORE

// Warpscript template
<'
  {
    'token' '{{ remoteReadToken }}'
    'class' '~.*'
    'labels' { 'company' 'cerenit' }
    'start' '2016-12-01T00:00:00Z'
    'end' '2022-01-31T00:00:00Z'
  } FETCH
'>
'fetchTpl' STORE

// Substitution dans le template
$fetchTpl
{ 'remoteReadToken' $sandboxReadToken } TEMPLATE
// Execution de la requête
$url REXECZ
// Renommage des séries
<% '+' RENAME %> F LMAP
// Presistence des données
$instanceWriteToken UPDATE

Bravo si vous m'avez suivi jusqu'ici, nous avons pu voir l'utilisation de :

  • Comment permettre un requêtage à distance d'une instance Warp 10 avec les fonctions REXEC et REXECZ
  • Comment traiter dynamiquement notre liste de GTS avec LMAP et une MACRO
  • Comment faire un template warpscript et de la substitution de variables avec TEMPLATE

Nous verrons dans un prochain épisode :

  • Comment utiliser Discovery pour faire nos premiers dashboards
  • Pourquoi/comment utiliser des macros coté server (spoiler: pour éviter que vos tokens se retrouvent dans votre navigateur à l'exécution des dashboards)

Ma comptabilité, une série temporelle comme les autres - partie 2 - actualisation des données et des prévisions

warp10timeseriescomptabilitéprévisionforecast

Suite de notre épopée :

L'année dernière, nous avions travaillé sur Warp 10 et mes données de comptabilité et jouer un peu avec les algo de prévision.

Les données comptables ayant été un peu ajustées entre temps et la librairie de prévision ayant aussi évolué coté SenX, les résultats ne sont plus tout à fait les mêmes. Nous allons donc reprendre tout ça.

Rappel des fais et prévisions à fin 2020

En septembre dernier, nous avions ce code pour avoir les données jusqu'au mois de Mai 2020 et une prévision jusqu'à la fin d'année:

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des données de dépenses / chiffre d'affaires / résult pour la période du 01/01/2017 -> 31/05/2020
// Chaque série est stockée dans une variable
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

// On affiche les trois courbes
$revenue
$exp
$result

// On génère et affiche les prévisions - on renomme les séries pour mieux les différencier ensuite au niveau dataviz
[ $result mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"forecast_result" RENAME

[ $revenue mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"forecast_revenue" RENAME

[ $exp mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"forecast_expense" RENAME

Au global :

warp10

Focus 2020 avec la partie prévision à partir de juin :

warp10

Si on fait la même chose en prenant un algo incluant un effet de saisonnalité :

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des données de dépenses / chiffre d'affaires / résult pour la période du 01/01/2017 -> 31/05/2020
// Chaque série est stockée dans une variable
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

// On affiche les trois courbes
$revenue
$exp
$result

[ $result mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"forecast_result" RENAME

[ $revenue mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"forecast_revenue" RENAME

[ $exp mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"forecast_expense" RENAME

Au global :

warp10

Focus 2020 avec la partie prévision à partir de juin :

warp10

On a bien un petit écart de comportement sur la prévision entre les deux modèles (focus sur 2020 avec les différentes prévisions à partir de juin) :

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des données de dépenses / chiffre d'affaires / résult pour la période du 01/01/2017 -> 31/05/2020
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

[ $result mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"sauto_result" RENAME

[ $revenue mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"sauto_revenue" RENAME

[ $exp mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"sauto_expense" RENAME

// On génère et affiche les prévisions - on renomme les séries pour mieux les différencier ensuite au niveau dataviz
[ $result mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"auto_result" RENAME

[ $revenue mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"auto_revenue" RENAME

[ $exp mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"auto_expense" RENAME

warp10

Prévisions vs réalité

Comparons maintenant les prévisions à la réalité - je vais rajouter les requêtes pour avoir la vue complète des données - pour éviter de trop surcharger le graphique, comme les séries forecast_* reprennent les données sources et y ajoutent la prévision, je ne vais afficher que ces séries et les séries réelles :

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des données de base qui serviront ensuite pour la prévision
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

// Récupération des données réelles de la période 01/01/2017 > 31/12/2020
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_result' STORE

// Génération des prévisions
// Pour SAUTO, il faut définir en plus un cycle, ici 12 mois
[ $result mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"forecast_result" RENAME

[ $revenue mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"forecast_revenue" RENAME

[ $exp mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"forecast_expense" RENAME

$real_result
$real_revenue
$real_exp

Ce qui nous donne au global :

warp10

et avec le focus 2020 :

warp10

Si on fait la même chose avec SAUTO

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des données de base qui serviront ensuite pour la prévision
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

// Récupération des données réelles de la période 01/01/2017 > 31/12/2020
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_result' STORE

// Génération des prévisions
[ $result mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"forecast_result" RENAME

[ $revenue mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"forecast_revenue" RENAME

[ $exp mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"forecast_expense" RENAME

$real_result
$real_revenue
$real_exp

Au global :

warp10

Focus 2020 avec la partie prévision à partir de juin :

warp10

Essayons d'analyser tout ça (il faut regarder les fins de mois - les points sont en date du dernier jour du mois) :

  • Pour Juin/Juillet, la prévision est plutôt bonne.
  • Pour Aout : l'écart vient du fait que j'ai pris mes vacances en aout et pas à cheval sur juillet/aout comme les autres années
  • Pour Septembre, c'est correct
  • Pour Octobre, il faut voir que j'ai tardé à éditer mes factures - elles ont donc été pris en compte sur Novembre - si on divise le montant de Novembre en deux, on retombe à peu près sur nos points
  • Pour décembre, un effet vacances également.

La pertinence est prévisions est donc plutôt correct au global et les écarts sont expliquables.

Consolidation annuelle

Et au niveau annuel ? Est-ce que les prévisions de chiffres d'affaires / dépenses / résultats sont bonnes si on ne tient plus compte des petits écarts de temps ci-dessus ?

Voyons celà :

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des différentes séries comme précédemment
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2020-06-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'real_result' STORE

// Calcul des prévisions comme précédemment
// Petit ajout, on stocke le résultat sous la forme d'une variable pour être réutilisé ultérieurement
[ $result mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"auto_result" RENAME
'auto_result' STORE

[ $revenue mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"auto_revenue" RENAME
'auto_revenue' STORE

[ $exp mapper.todouble 0 0 0 ] MAP
AUTO 7 FORECAST.ADDVALUES
"auto_expense" RENAME
'auto_expense' STORE

// Aggrégation annuelle
// Utilisation de BUCKETIZE.CALENDAR et de la macro BUCKETIZE.byyear qui s'appuie dessus et qui permet de faire une aggrégation annuelle sur des données
// bucketizer.sum permet d'appliquer une somme sur les données regroupées par année
// UNBUCKETIZE.CALENDAR permet de retransformer l'indice issue de BUCKETIZE.CALENDAR en timestamp
[ $real_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR
[ $real_result bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR
[ $real_exp bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR

[ $auto_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR
[ $auto_result bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR
[ $auto_expense bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR


// Meme chose pour SAUTO
[ $result mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"sauto_result" RENAME
'sauto_result' STORE

[ $revenue mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"sauto_revenue" RENAME
'sauto_revenue' STORE

[ $exp mapper.todouble 0 0 0 ] MAP
12 SAUTO 7 FORECAST.ADDVALUES
"sauto_expense" RENAME
'sauto_expense' STORE

[ $sauto_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR
[ $sauto_result bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR
[ $sauto_expense bucketizer.sum ] @senx/cal/BUCKETIZE.byyear UNBUCKETIZE.CALENDAR

Pour expliciter un peu au dessus :

On veut obtenir un résultat annuel couvant la période du 01/01 au 31/12 d'une année. Il faut donc prendre tous les points de l'année en question et en fait la somme.

Si on fait:

[ $real_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear

On obtient :

[{"c":"revenue","l":{"company":"cerenit",".app":"52274aa9-8242-49ee-b3e8-dbc6f514999d",".uuid":"52274aa9-8242-49ee-b3e8-dbc6f514999d"},"a":{".buckettimezone":"UTC",".bucketduration":"P1Y",".bucketoffset":"0"},"la":1612528364518,"v":[[47,100850],[48,132473],[49,151714],[50,139146]]}]

Les valeus obtenues sont :

[[47,100850],[48,132473],[49,151714],[50,139146]]

Les indices 47, 48, 49, 50 sont en fait un delta par rapport au 01/01/70. En effet, 2020 = 1970 + 50

En appliquant UNBUCKETIZE.CALENDAR, on retransforme ce 50 par ex en son équivalent sous la forme d'un timestamp : 1609459199999999.

On peut aussi utiliser TIMESHIFT de la façon suivante :

[ $real_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT

Pour obtenir pour la partie valeur :

[[2017,100850],[2018,132473],[2019,151714],[2020,139146]]

Pour en savoir plus sur BUCKETIZE.CALENDAR et ses utilisations : Aggregate by calendar duration in WarpScript

Une fois qu'on reprend toutes ses données, on peut essayer de mesurer les écarts entre le réél et les prévisions des deux modèles :

AUTOSAUTORéelAUTO vs RéelSAUTO vs Réel
Chiffre d'affaires144.029125.128139.146-3,39%+11,20%
Dépénses117.701113.765129.464+9,99%+13,80%
Résultat14.75416.8939.682-34,38%-42,69%
Résultat corrigé26.32811.3639.682-63,23%-14,79%

Intéressant, la prévision de résultat n'est pas égale à la différence entre la prévision de chiffre d'affaires et la prévision des dépenses ! C'est la raison de la ligne "Résultat corrigé".

A ce stade, il ne me semble pas possible de privilégier un modèle plus qu'un autre - même si du fait de la récurrence des vacances, on peut supposer que le modèle avec saisonnalité pourrait être plus pertinent.

Prévisions pour 2021

Pour aller au bout de cet exerice, il ne reste plus qu'à voir ce que nos algoritmes prévoient pour 2021 :

'<read token>' 'readToken' STORE
'<write token>' 'writeToken' STORE

// Récupération des séries 2017 > 2020
[ $readToken 'expense' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'exp' STORE

[ $readToken 'revenue' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'revenue' STORE

[ $readToken 'result' { 'company' '=cerenit' } '2016-12-01T00:00:00Z' '2021-01-01T00:00:00Z' ] FETCH
0 GET
'result' STORE

// Prévision sur les 12 prochains mois
[ $result mapper.todouble 0 0 0 ] MAP
AUTO 12 FORECAST.ADDVALUES
"auto_result" RENAME
'auto_result' STORE

[ $revenue mapper.todouble 0 0 0 ] MAP
AUTO 12 FORECAST.ADDVALUES
"auto_revenue" RENAME
'auto_revenue' STORE

[ $exp mapper.todouble 0 0 0 ] MAP
AUTO 12 FORECAST.ADDVALUES
"auto_expense" RENAME
'auto_expense' STORE

// Consolidation annuelle avec AUTO
[ $auto_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
[ $auto_result bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
[ $auto_expense bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT

// Prévisions avec SAUTO
[ $result mapper.todouble 0 0 0 ] MAP
12 SAUTO 12 FORECAST.ADDVALUES
"sauto_result" RENAME
'sauto_result' STORE

[ $revenue mapper.todouble 0 0 0 ] MAP
12 SAUTO 12 FORECAST.ADDVALUES
"sauto_revenue" RENAME
'sauto_revenue' STORE

[ $exp mapper.todouble 0 0 0 ] MAP
12 SAUTO 12 FORECAST.ADDVALUES
"sauto_expense" RENAME
'sauto_expense' STORE

// Consolidation annuelle avec SAUTO
[ $sauto_revenue bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
[ $sauto_result bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT
[ $sauto_expense bucketizer.sum ] @senx/cal/BUCKETIZE.byyear 1970 TIMESHIFT

On passe tout ça dans le shaker et on obtient :

Prévu avec AUTOPrévu avec SAUTO
Chiffre d'affaires78.230129.465
Dépénses118.383110.434
Résultat prévu5.7304.049
Résultat corrigé-40.15319.031

Rendez-vous à la fin de l'année pour voir ce qu'il en est... et on peut espérer que la réalité sera proche du modèle avec saisonnalité !

Pour le moment, on travalle toujours dans le WarpStudio et on voudrait bien avoir des (jolis) dashboards qui font tout ça pour nous plutôt que de copier/coller du Warpscript. Ce sera le sujet de la partie 3.

Web, Ops & Data - Janvier 2021

timeseriesprometheuspromqlovhcloudiotopenhabvectortimescaledbptsmanomalielabelmachine-learningiacansiblelibsshvectorlogwarp10influxdbopensshgpgpodmandocker-composesudo

Cloud

Code

  • GitLab release feature report : le code qui permet de générer le rapport ce qui a changé entre les versions de Gitlab.
  • SSH is the new GPG : les dernières versions d'OpenSSH permettent de signer un fichier. Une solution intermédiaire entre de la signature de fichiers à base de MD5 & co qui donnent des informations de conformité mais sans indiquer qui a signé le fichier et une solution GPG plus complexe à mettre en oeuvre ?

Container et orchestration

  • Using Podman and Docker Compose : podman, le "daemonless container engine" va permettre d'être utilisé avec docker-compose dans le cadre de la version 3.0. De quoi favoriser l'adoption de podman ?

Infra as code

  • New LibSSH Connection Plugin for Ansible Network Replaces Paramiko, Adds FIPS Mode Enablement : Ansible change de librairie pour les connexions ssh en remplaçant paramiko par libssh. Elle se veut plus performante et peut être requis dans un contexte demandant du FIPS. Pensez à installer le paquet libssh-dev(el) suivant votre distribution pour pouvoir installer ansible-pylibssh. Mes premiers essais ne notent pas une amélioration sensible des performances... à voir sur d'autres machines et dans la durée...

IoT

  • openHAB 3.0 Release et Release Notes : OpenHAB est une plateforme open source de gestion de périphétiques IoT et d'automatisation autour de ces périphériques. Elle est développée en Java, support 2000 "Things" (objets, équipements, protocoles). La version 3.0 apporte une refonte et l'unification de l'UI et des composants, le passage à Java 11 et plein d'autres choses. La migration depuis une version 2.x se fait assez simplement. Avec le nouveau moteur de règle, j'ai pu supprimer mon code spécifique. Reste encore la partie "Pages" à appréhender... J'avais préféré OpenHAB à Jeedom et Home Assistant
  • Meet Raspberry Silicon: Raspberry Pi Pico now on sale at $4 : la fondation Raspberry Pi se lance dans les micro-controlleurs avec le Pico au prix de 4$.
  • Raspberry Pi PICO la carte Microcontrôleur de la Fondation : un article très détaillé sur la prise en main du pico.

Observabilité

Système

Time Series

Bilan 2020 et perspectives 2021

bilanperspectivecérénittimeseriesbigdatahebdoinfluxaceiot

Routine habituelle de début d'année pour la clôture de ce 4ème exercice (déjà !).

Bilan 2020

Au global, une bonne année au regard des conditions - les objectifs sont remplis.

D'un point de vue comptable, cela donne :

2020201920182017Variation n/n-1
Chiffre d'affaires~138 K€~150 K€~132 K€~100 K€-8%
Résultat après impôts~8 K€~13.5 K€~10 K€~20 K€-41%
Jours facturés175197178160-11%
TJM789€761€742€625€+3.6%

Contrairement aux autres années, les jours facturés ne prennent plus en compte des prestatations forfaitaires (comme l'infogérance, etc) pour lesquelles je faisais un équivalent jour. J'ai ajusté les valeurs de ce tableau mais je n'ai pas mis à jour les synthèses 2019, 2018 et 2017. Cela a pour conséquence d'améliorer sensiblement le TJM.

L'épisode COVID n'a pas eu d'impact direct sur mon activité et je fais un chiffre d'affaire conforme à ce que j'avais prévu en début d'année. Clairement, je mesure ma chance d'avoir passé cette année sans encombres professionnels. J'avais dit que je passerai à 4/5 sur l'année. Dès lors je ne pouvais envisager de factuer plus de 80% des jours ouvrés et et je parviens à en factuer 77% (toujours hors prestatations forfaitaires). En faisait un TJM de 700€ et 80% des jours ouvrés, cela me donnait un chiffre d'affaires à atteindre de 128 K€. J'atteins à peu près cet objectif avec les jours facturés et je le dépasse grâce aux prestations forfaitaires. Ces prestations forfaitaires ayant sensiblement augmenté en 2020 (passage de ~10K€ à ~13K€) et même si l'une d'entre elles a généré un investissement matériel important et qui sera compensé sur les prochaines années. Cela explique principalement la chute du résultat (si on prend 2018 comme année de comparaison, pour une chiffre d'affaire et un volume de jours facturés similaire, le résultat est 20% inférieur).

Comme chaque année, j'en profite pour remercier Fabrice pour son accompagnement en tant qu'expert-comptable. Je le dis et le répête, mais avoir confiance dans son expert comptable et pouvoir compter sur lui pour apporter de bons conseils aux bons moments et être serein sur la gestion de l'entreprise, c'est indispensable - surtout en cette période. Même si je n'en ai pas bénéficié directement, les informations transmises pendant cette période sur les aides et autres mécanismes mis en place ont été très utiles.

D'un point de vue activité, c'est une bonne année en termes de contenus de missions :

Pour le reste, j'ai le plaisir de :

Petite déception toutefois sur la partie développement, où je n'ai pas pu me mettre sérieusement à Go ou Rust.

Enfin, je m'étais posé la question du rôle social d'une entreprise dans notre société en temps de COVID. Ma contribution a certes été modeste dans la limite de ce qui était autorisé par la loi d'une part et ne sachant pas trop comment se finirait l'année d'autre part. Je pense que je vais continuer dans cette voie et voir quel(s) projet(s) je pourrai soutenir en 2021. Content d'avoir contribué au projet Makair et de voir comment il évolue en tous cas.

Perspectives 2021

L'année commence bien avec la suite de la mission Warp 10/InfluxDB dans le monde nautique mentionnée précédemment. A celà s'ajoute une autre mission de conseil autour des usages de séries temporelles pour un autre acteur de l'énergie. J'ai du décliner un troisième appel d'offre sur un sujet similaire du fait de mes engagements actuels, mais j'espère qu'il y aura d'autres projets similaires.

Ayant aussi découvert le monde de l'impression 3D durant le premier confinement et plus récemment à jouer avec des cartes micro:bit (et peut être bientôt des ESP32), j'irais bien voir du coté de l'IoT et donner une dimension "plus industrielle" à mes usages de séries temporelles. Sortir des usages de monitoring serveur pour les séries temporelles et aller vers des usages plus industriels ou métiers est clairement intéressant. Osons le terme: direction l'industrie 4.0 !

Pour rebondir sur cette dimension usage, j'ambitionne pour le Paris Time Series Meetup d'avoir un focus usage plus important et avoir des retours d'expérience (et moins de présentation produit par des éditeurs).

Sur BigData Hebo, nous venons de lancer les brèves afin de mettre en avant les contributions des membres de la communauté. A suivre !

Pour le développement en Go et Rust, le premier devrait voir le jour dans l'année de façon assez certaine, c'est plus incertain pour le second.

Et enfin, pour le projet commencé en septembre et dont je ne peux pas encore parler, j'espère pouvoir lever le voile prochainement !

Si certains sujets vous interpellent ou si vous avez des contacts à me suggérer, n'hésitez pas à me contacter.

Web, Ops & Data - Décembre 2020

podmandockerruncrootlessswarmcgroupskubernetescridockershimvectorawstimestreamwarp10dashboardptsmtimescalecentosrhelpodmaninfluxdbtimeseries

Containers et orchestration

  • Electro Monkeys #37 – Podman, l’alternative de Redhat à Docker avec Benjamin Vouillaume : je me demandais si podman permettait un management de containers à distance et l'étendue de son écosystème. Le podcast permet de compléter le tour du propriétaire et de se faire une bonne idée de son positionnement.
  • How to deploy on remote Docker hosts with docker-compose : dans la même quête, je me demandais s'il était possible de piloter un noeud docker à distance quand je me suis rappelé qu'il était possible de le faire au travers d'une connexion ssh. En creusant un peu plus, j'ai découvert la notion de contexte qui permet ainsi de cibler un noeud docker, docker swarm ou kubernetes.
  • Don't Panic: Kubernetes and Docker et Dockershim Deprecation FAQ : A partir de kubernetes 1.20 (fin 2020) et définitivement à partir de la version 1.23 (fin 2021), le retrait du binaire docker comme CRI de Kubernetes est annoncé. Cela ne devrait pas changer grand chose et c'est essentiellement de la plomberie interne. Plutôt que de passer par Dockershim qui implémentait l'interface CRI et qui discutait ensuite avec Docker pour lancer les conteneurs via containerd, l'appel sera directement fait à containerd. Il n'y a que ceux qui montent la socket docker dans les pods qui vont avoir un souci. Si c'est pour builder des images, il y a des alternatives comme img, kaniko, etc. Pour les autres cas, il faudra peut être passer par l'API kubernetes ou trouver les alternatives qui vont bien.
  • What developers need to know about Docker, Docker Engine, and Kubernetes v1.20 et Mirantis to take over support of Kubernetes dockershim : Mirantis et Docker Inc vont assurer le support de cette interface dockershim pour permettre à ceux qui ont en besoin de pouvoir continuer à l'utliiser. La limite étant que si vous êtes sur du service managé et que votre provider ne le fournit pas, vous ne pourrez pas l'utiliser...
  • Kubernetes 1.20: The Raddest Release : voilà, la version 1.20 est sortie et apporte son lot de nouveautés et de stabilisation.
  • Announcing General Availability of HashiCorp Nomad 1.0 : Nomad 1.0 est également disponible.
  • Introducing Docker Engine 20.10 - Docker Blog : Docker 20.10 arrive avec des profondes nouveautés comme le support des cgroupsv2 et un mode rootless, docker logs fonctionne avec tous les drivers de log et non unqiement json & journald et plein d'autres améliorations/harmonisations au niveau de la CLI. Pour ceux sous Fedora qui avaient bidouillé avec firewalld précédemment pour faire fonctionner docker et qui ont un problème lié à l'interface docker0 au démarrage du service docker, allez voir par ici.
  • Docker Engine Release Notes - Version 20.10 : En plus des points précédents, il y a l'arrivée des jobs dans swarm - depuis le temps que je l'attendais 🤩 (même si on peut se toujours se poser la question de la pérénnité de swarm depuis qu'il a été racheté par Mirantis)
  • New features in Docker 20.10 (Yes, it’s alive) : un billet décrivant plus en détail certaines feautres de docker 20.10 comme support Fedora/CentOS, le rootless mode, l'option -mount, les jpbs swarm et une synthèse de l'actualité de l'écosystème docker.
  • Podman Release 2.2.0 : ajout des commandes network (dis)connect, support des alias avec des noms courts, amélioration des commandes play|generate kube et capacité de monter une image OCI dans un container.

Observabilité

  • Vector - Collect, transform, & route all observability data with one simple tool. (via) : vector est un outil en rust qui permet de collecter et manipuler des métriques/logs/événements et de les envoyer vers différentes destinations. De quoi remplacer filebeat/journalbeat voire même telegraf ? ;-)
  • Our new partnership with AWS gives Grafana users more options : AWS vient d'annoncer un service managé pour Prometheus basé sur Cortex et pour Grafana (version Entrepsise). Grafana et Cortex étant des projets édités par Grafana Labs sous licence OSS (et des déclinaisons Cloud et Entreprise). AWS changerait-il sa façon de travailler avec les projets OSS lorsqu'il souhaite en faire des services managés et prendre ainsi une attitude plus positive vis à vis de la communauté OSS ?

OSS

  • Death of an Open Source Business Model : Analyse du passage de Mapbox GL JS v2 sous licence propriétaire, le modèle de l'open core et les menaces des top cloud providers sur le reste de l'économie du logiciel. On peut étendre cela aussi "VC funded OSS company".

Système

  • CentOS Project shifts focus to CentOS Stream, CentOS Stream: Building an innovative future for enterprise Linux et la FAQ associée : Historiquement CentOS Linux était batie sur les sources de Red Hat Entreprise Linux un fois celle-ci disponible. CentOS Stream renverse un peu la tendance avec un cycle d'intégration plutôt Fedora -> CentOS Stream -> RHEL. L'initiative CentOS Stream avait été annoncée en septembre 2019 et ce changement permet donc aux équipes CentOS de se focoaliser sur une seule version (CentOS Stream) et non plus deux versions (CentOS Stream et CentOS Linux). CentOS Linux 7 sera maintenue jusqu'à la fin du support de RHEL 7 et et CentOS 8 jusqu'à fin 2021 (et non pas 2029 comme prévu). Il n'y aura pas de version 9 de CentOS Linux. A tester pour voir si CentOS Stream est plus stable que Fedora mais moins conservateur que RHEL et pourrait alors s'avérer être un bon compromis.
  • Before You Get Mad About The CentOS Stream Change, Think About… : un billet assez long d'un employé de Red Hat qui exprime son opinion et cherche à remettre les choses en perspective en dépassionnant le débat.

Time Series

  • PTSM Edition #8 - Amazon TimeStream 101 : Edition du Paris time Series Meetup sur AWS Timestream
  • TimescaleDB vs. Amazon Timestream: 6000x higher inserts, 5-175x faster queries, 150x-220x cheaper : avec toutes les réserves habitudelles sur les benchmarks, Timescale a comparé son produit avec AWS Timestream et la conclusion semble clairement en faveur de Timescaledb. A noter que l'update des données est arrivé dans AWS Timestream entre temps.
  • Truly Dynamic Dashboards as Code : les équipes de SenX ont implémenté leur vision du "Dashboards as Code" sous le nom Discovery. Discovery veut aller plus loin que la simple description d'un dashboard comme on peut le voir dans Grafana ou InfluxDB 2.0 en apportant une touche de dynamisme et de génération dynamique des dashboards en fonction des élements obtenus (ex si valeur X > Y alors afficher la procédure AAA de résolution d'incident). J'ai commencé à jouer avec, un billet de blog sur ce sujet devrait bientôt arriver.
  • NeuralProphet : un modèle neuronal orienté série temporelles, inspiré de Facebook Prophet et développé avce PyTorch.
  • Release Notes InfluxDB 2.0.3 et Release Announcement: InfluxDB OSS 2.0.3 : build arm64 en preview, un petit conflit de packaging entre influxdb et influxdb2 à passer pour ceux qui étaient déjà en 2.0 et ceci afin d'éviter que des gens en 1.x passent involontairement en 2.x, le "delete with predicate" a été réactivé, améliorations sur le process d'upgrade, des commandes autour des actions en mode V1, mise à jour de flux, et plein d'autres corrections/améliorations.

Web

  • Web Almanac 2020 - Rapport annuel de HTTP Archive sur l’état du Web : une synthèse de l'état du web d'après HTTP Archive sur une base de 7.5 millions de sites testés, soit 31.3 To de données traitées. De quoi relativisez un peu les biais de notre bulle technologique : non tout le monde ne fait pas du React/Angular/Vue.js par ex mais plutôt du JQuery ! Suivant vos usages, plein d'enseignements et de choses intéressantes à tirer de cette synthèse.

Il ne me reste plus qu'à vous souhaiter de bonnes fêtes de fin d'année et on se retrouve l'année prochaine !

InfluxDB 2.0 OSS - Notes de mise à jour

timeseriesinfluxdbfluxgrafanatelegraf

InfluxDB 0SS 2.0 étant sortie, j'ai testé la mise à jour d'une instance 1.8.3 vers 2.0.1 sur une VM Debian 10 à jour.

Mise à jour

La documentation pour une mise à jour 1.x vers 2.x est disponible. La vidéo "Path to InfluxDB 2.0: Seamlessly Migrate 1.x Data" reprend cela et va plus loin en présentant bien tous les points à prendre en compte (y compris pour Telegraf, Chronograf et Kapacitor). Je ne rajouterai donc que mes remarques.

Concernant la commande influxd upgrade :

  • Il est fort probable qu'il faille rajouter la commande sudo pour ne pas avoir de problèmes de permisisons.
  • Par défaut, les données migrées vont être mises dans ~/.influxdbV2. Or je doute que vous vouliez que vos données soient à cet endroit. Je vous invite donc à regarder la documentation de influxd upgrade pour définir les propriétés --engine-path et --bolt-path

Exemple:

mkdir -p /srv/influxdb/influxdb2
influxd upgrade --engine-path /srv/influxdb/influxdb2/engine --bolt-path /srv/influxdb/influxdb2/influxd.bolt
  • A l'issue de la migration, un fichier config.toml est généré dans /etc/influxdb/. Il contient quelques valeurs issues de la migration et des valeurs par défaut. Je l'ai personnalisé de la façon suivante pour tenir compte de mes valeurs :
bolt-path = "/srv/influx/influxdb2/influxd.bolt"
engine-path = "/srv/influx/influxdb2/engine"
http-bind-address = "127.0.0.1:8086"
storage-series-id-set-cache-size = 100
  • Post-migration, le service influxd cherchait à initialiser ses fichiers dans /var/lib/influxdb/.influxdbv2. Ayant noté que le service InfluxDB prennait /etc/default/influxdb comme fichier d'environnement, j'ai ajouté dans ce fichier :
# /etc/default/influxdb
INFLUXD_CONFIG_PATH=/etc/influxdb/config.toml

Dès lors, /etc/influxdb/config.toml était bien pris en compte et InfluxDB démarrait bien avec mes données.

Une fois InfluxDB 2 démarré, j'ai pu noter avec plaisir :

  • que l'ingestion via telegraf continuait à se faire sans interruption,
  • que mes dashboards Grafana continuaient à fonctionner.

Je n'ai donc pas d'urgence à migrer la configuration et le paramétrage de ces derniers. Je vais pouvoir le faire progressivement ces prochains jours.

N'utilisant pas Chronograf et Kapacitor, je n'ai pas eu de données à migrer ou d'ajustements à faire à ce niveau là. La vidéo reprend bien les points d'attention et les éventuelles limitations à prendre en compte dans le cadre de la migration.

Finalement, c'est pas mal qu'ils aient réintégrer les endpoints 1.x dans la version 2.0 à ce niveau là ;-)

La 2.0.2 étant sortie pendant ma mise à jour, j'ai poursuivi la mise à jour. Je suis tombé sur ce bug rendant l'écriture de données impossibles. Cela a mis en évidence un bug sur la migration des "retention policies" et sur le fait que j'avais aussi des très vieilles bases InfluxDB. Je n'aurai a priori pas eu ce bug en faisant la migration 1.8.3 vers 2.0.2. En tous cas, une 2.0.3 devrait donc arriver prochainement avec une amélioration du processus de migration faisant suite à ma séance de troubleshooting.

Migration des configurations

Elle peut se faire très progressivement - si par ex vous utilisez telegraf pour envoyer vos données et Grafana pour la partie dashboarding :

  • Vous pouvez mettre à jour votre configuration telegraf en passant de l'outputs influxdb à l'output influxdb_v2 sans impacter grafana qui continuera à accéder à vos données en InfluxQL
  • Vous pouvez ensuite mettre à jour votre datasource InfluxDB ou plutôt en créer une nouvelle et migrer vos dashboards progressivement sans interruption de service

Créer un accès en InfluxQL à un nouveau bucket

Si vous devez rétablir un accès à vos données via les API 1.x à un bucket nouvellement créé (j'ai profité de la migration pour mettre des buckets clients dans des organisations représentant les clients en question).

# Créer le bucket
influx bucket create --name <BUCKET_NAME> --retention 0  --org <ORGANISATION>
# Récupérer l'ID de bucket via la liste des buckets
influx bucket list
# Créer une DBRP (DataBase Retention Policies) pour le bucket en question - les accès en 1.x se font en mode  SELECT * FROM <db_name>.<retention_policies> ...
influx v1 dbrp create --bucket-id=<BUCKET_ID> --db=<BUCKET_NAME> --rp=autogen --default=true
# Créer un utilsateur sans mot de passe pour le moment
influx v1 auth create --username <USER> --read-bucket <BUCKET_ID> --write-bucket <BUCKET_ID> --org <ORGANISATION> --no-password
# Créer un mot de passe au format V1
influx v1 auth set-password --username <USER>

Les utilisateurs migrés depuis la version 1.x sont visibles via influx v1 auth list.

Intégration InfluxDB 2.0 / Flux et Grafana

Le support de Flux dans Grafan existe depuis la version 7.1 mais il n'est pas aussi aisé que celui dans InfluxDB 2.0 OSS. Il y a certes de la complétion au niveau du code ou le support des variables mais pas de capacité d'introspection sur la partie données.

Pour le moment, je procède donc de la façon suivante :

  • Création de la Requête via le Query Builder dans InfluxDB 0SS
  • Passage en mode "Script editor" pour dynamiser les variables ou ajuster certains comportements
  • Copier/coller dans l'éditeur de Grafana
  • Ajustement des variables pour les mettre au format attendu par Grafana.

Ex coté InfluxDB 2.0 OSS / Flux :

from(bucket: v.bucket)
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "net")
  |> filter(fn: (r) => r["_field"] == "bytes_recv" or r["_field"] == "bytes_sent")
  |> filter(fn: (r) => r["host"] == v.host)
  |> derivative(unit: v.windowPeriod, nonNegative: false)
  |> yield(name: "derivative")

La version dans Grafana :

from(bucket: "${bucket}")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r["_measurement"] == "net")
|> filter(fn: (r) => r["_field"] == "bytes_recv" or r["_field"] == "bytes_sent")
|> filter(fn: (r) => r["host"] == "${host}")
|> derivative(unit: v.windowPeriod, nonNegative: false)
|> yield(name: "derivative")

La différence portant sur la gestion des variables v.host vs "${host}" et v.bucket vs "${bucket}".

Autre bonne nouvelle, les variables sont supportées dans Grafana ; vous pouvez donc définir les variables comme celles vu juste au-dessus :

Variable bucket de type "Query" en prenant InfluxDB/Flux comme datasource :

buckets()
  |> filter(fn: (r) => r.name !~ /^_/)
  |> rename(columns: {name: "_value"})
  |> keep(columns: ["_value"])

Variable host de type "Query" en prenant InfluxDB/Flux comme datasource :

# Provide list of hosts
import "influxdata/influxdb/schema"
schema.tagValues(bucket: v.bucket, tag: "host")

Si votre requête fonctionne dans un dashboard InfluxDB ou en mode explore mais qu'elle est tronquée dans Grafana, il vous faudra ajuster le "Max Data Points" pour récupérer plus de points pour cette requête (cf grafana/grafana#26484).

InfluxDB 2.0 OSS - Grafana - Max Data Points

Calcul de la durée d'un état avec des timeseries

timeseriesinfluxdbfluxwarp10warpscriptduration

Un client m'a demandé la chose suivante : "Nicolas, je voudrais savoir la durée pendant laquelle mes équipements sont au delà d'un certain seuil ; je n'arrive pas à le faire simplement".

Souvent, quand on manipule des séries temporelles, la requête est de la forme "Sur la période X, donne moi les valeurs de tel indicateur". On a moins l'habitude de travailler dans le sens inverse, à savoir : "Donne moi les périodes de temps pour laquelle la valeur est comprise entre X et Y".

C'est ce que nous allons chercher à trouver.

Influx 1.8 et InfluxQL

Avec l'arrivée imminente d'Influx 2.0, j'avoue ne pas avoir cherché la solution mais je ne pense pas que cela soit faisable purement en InfluxQL.

Influx 1.8 / 2.0 et Flux

Avec Flux, j'ai rapidement trouvé des fonctions comme duration et surtout stateDuration

L'exemple ci-dessous se fait avec une base InfluxDB 1.8.3 pour laquelle Flux a été activé. Le requêtage se fait depuis une instance Chronograf en version 1.8.5.

Pour approcher l'exemple de mon client, j'ai considéré le pourcentage d'inactivité des CPU d'un serveur que l'on obtient de la façon suivante:

from(bucket: "crntbackup/autogen")
  |> range(start: dashboardTime)
  |> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_idle" and r.cpu == "cpu-total")
  |> window(every: autoInterval)
  |> group(columns: ["_time", "_start", "_stop", "_value"], mode: "except")

Cela donne:

flux - duration 1

Ensuite, j'ai besoin d'une fonction qui va me rajouter une colonne avec mon état. Cet état est calculé en fonction de seuils - par souci de lisibilité, je vais extraire cette fonction de la façon suivante et appliquer la fonction à ma requête :

set_level = (tables=<-) =>
  tables
    |> map(fn: (r) => ({
      r with
      level:
        if r._value >= 95 then "fully_idle"
        else if r._value >= 90 and r._value <95 then "something_is_moving"
        else if r._value >= 85 and r._value <90 then "oh_oh"
        else if r._value >= 80 and r._value <85 then "hmm"
        else if r._value < 80 then "i_have_to_work"
        else "overloaded"
      })
    )

from(bucket: "crntbackup/autogen")
  |> range(start: dashboardTime)
  |> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_idle" and r.cpu == "cpu-total")
  |> window(every: autoInterval)
  |> group(columns: ["_time", "_start", "_stop", "_value"], mode: "except")
  |> set_level()

La colonne "level" n'est à ce stade pas persistée en base contrairement aux autres données issue de la base de données.

Cela donne ceci en mode "raw data" - tout à fait à droite

flux - duration 2

Maintenant que j'ai mon état, je peux application la fonction stateDuration() ; elle va calculer la périodes de temps où le seuil est "something_is_moving" par tranche de 1 seconde. Le résulat sera stocké dans une colonne "stateDuration". Pour les autres états, la valeur est de -1. La valeur se remet à 0 à chaque fois que l'état est atteint puis la durée est comptée :

set_level = (tables=<-) =>
  tables
    |> map(fn: (r) => ({
      r with
      level:
        if r._value >= 95 then "fully_idle"
        else if r._value >= 90 and r._value <95 then "something_is_moving"
        else if r._value >= 85 and r._value <90 then "oh_oh"
        else if r._value >= 80 and r._value <85 then "hmm"
        else if r._value < 80 then "i_have_to_work"
        else "overloaded"
      })
    )

from(bucket: "crntbackup/autogen")
  |> range(start: dashboardTime)
  |> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_idle" and r.cpu == "cpu-total")
  |> window(every: autoInterval)
  |> group(columns: ["_time", "_start", "_stop", "_value"], mode: "except")
  |> set_level()
  |> stateDuration(fn: (r) => r.level == "something_is_moving", column: "stateDuration", unit: 1s)

On voit le rajout de la colonne stateDuration en mode "raw data" ; elle n'ont plus n'est pas persistée dans la base à ce stade :

flux - duration 4

et coté visualisation :

flux - duration 3

Maintenant que j'ai ces périodes, je vais vouloir savoir quelle est la durée totale de ces différentes périodes que nous avons identifée. On peut en effet imaginer un cas où on sait que l'équipement est à remplacer lorsqu'il a atteint un seuil donné pendant plus de X heures.

Pour cela, je vais:

  • filtrer sur un état voulu,
  • calculer le différentiel entre chaque valeur de stateDuration pour n'avoir que les écarts non plus la somme des durées en supprimant les valeurs négatives pour gérer les retours à la valeur 0
  • et faire la somme de l'ensemble.
set_level = (tables=<-) =>
  tables
    |> map(fn: (r) => ({
      r with
      level:
        if r._value >= 95 then "fully_idle"
        else if r._value >= 90 and r._value <95 then "something_is_moving"
        else if r._value >= 85 and r._value <90 then "oh_oh"
        else if r._value >= 80 and r._value <85 then "hmm"
        else if r._value < 80 then "i_have_to_work"
        else "overloaded"
      })
    )

from(bucket: "crntbackup/autogen")
  |> range(start: dashboardTime)
  |> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_idle" and r.cpu == "cpu-total")
  |> window(every: autoInterval)
  |> group(columns: ["_time", "_start", "_stop", "_value"], mode: "except")
  |> set_level()
  |> stateDuration(fn: (r) => r.level == "something_is_moving", column: "stateDuration", unit: 1s)
  |> filter(fn: (r) => r.level == "something_is_moving")
  |> derivative(unit: 10s, nonNegative: true, columns: ["stateDuration"], timeColumn: "_time")
  |> sum(column: "stateDuration")

Ce qui me donne un total de 2230 secondes pour l'heure (3600s) qui vient de s'écouler.

flux - duration 5

C'est un POC rapide pour démontrer la faisabilité de la chose. Le code est surement améliorable/perfectible.

Dans un contexte InfluxDB 2.0, il y a aussi la fonction events.duration qui semble intéressante. Ce billet "TL;DR InfluxDB Tech Tips – How to Monitor States with InfluxDB" montre aussi l'usage de la fonction monitor.stateChanges() qui peut compléter l'approche.

Influx 1.8 / Flux - variante pour les séries irrégulières

La fonction derivative impose d'avoir des durées régulières pour calculer le delta. Dans le cas d'une série irrégulière, cela peut coincer rapidement et fausser les calculs. On peut donc remplacer les deux dernières lignes par la fonction increase. Elle prend la différence entre deux valeurs consécutives (quelque soit leur timestamp) et réalise une somme cumulative. Les différences négatives sont ignorées de la même façon que nous le faisions précédemment.

set_level = (tables=<-) =>
  tables
    |> map(fn: (r) => ({
      r with
      level:
        if r._value >= 95 then "fully_idle"
        else if r._value >= 90 and r._value <95 then "something_is_moving"
        else if r._value >= 85 and r._value <90 then "oh_oh"
        else if r._value >= 80 and r._value <85 then "hmm"
        else if r._value < 80 then "i_have_to_work"
        else "overloaded"
      })
    )

from(bucket: "crntbackup/autogen")
  |> range(start: dashboardTime)
  |> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_idle" and r.cpu == "cpu-total")
  |> window(every: autoInterval)
  |> group(columns: ["_time", "_start", "_stop", "_value"], mode: "except")
  |> set_level()
  |> stateDuration(fn: (r) => r.level == "something_is_moving", column: "stateDuration", unit: 1s)
  |> filter(fn: (r) => r.level == "something_is_moving")
  |> increase(columns: ["stateDuration"])

La sortie change un peu car au lieu d'un nombre unique, on a l'ensemble des points filtrés et leur somme au fur et à mesure (colonne de droite):

flux - duration increase dataviz

Cela donne des possiblités différentes au niveau dataviz :

flux - duration increase dataviz

Warp 10 / WarpScript

En la même chose en WarpScript avec Warp 10, cela donne quoi ? Regardons cela :

'<readToken>' 'readToken' STORE

// Récupération des données de cpu de type "usage_idle" en ne prenant que le label "cpu-total"
[ $readToken '~crntd10monitoring.cpu.usage_idle' { 'cpu' 'cpu-total' } NOW 1 h ] FETCH
0 GET         // Fetch retourne une liste de GTS, on prend donc la première (et unique) GTS
'cpu' STORE   // Stockage dans une variable cpu

// Utilisation de BUCKETIZE pour créer une série régulière de données séparées par 1 seconde
// Mes données étant espacées d'environ 10s, cela va donc créer 10 entrées de 1 seconde au final
// Pour chaque espace, on utliise la dernière valeur connue de l'espace en question pour garder les valeurs de la GTS de départ
[
  $cpu
  bucketizer.last
  0
  1 s
  0
]
BUCKETIZE
// Les espaces insérés n'ont pas encore de valeurs associées
// On remplit les entrées sans valeurs avec les valeurs ci-dessus
// On utilise FILLPREVIOUS et FILLNEXT pour gérer aussi les premières et dernières valeurs
FILLPREVIOUS
FILLNEXT
// A ce stade, on a donc une GTS avec un point toute les secondes et la valeur associée. Cette valeur était la valeur que l'on avait toutes les 10s précédemment.

// On fait une copie de la GTS pour pouvoir comparer avec la version filtrée par ex
DUP

// On filtre sur les valeurs qui nous intéressent, ici on veut les valeurs >= 90 et < 95
[ SWAP 90.0 mapper.ge 0 0 0 ] MAP
[ SWAP 95.0 mapper.lt 0 0 0 ] MAP
// On renomme la liste (pratique si on affiche par ex l'ancienne et la nouvelle liste dans la partie dataviz - cf capture ci-dessous)
'+:above90below95' RENAME

// On compte le nombre d'élément de la GTS qui est sous la forme d'une liste de GTS à l'issu du MAP
0 GET SIZE

// On multiplie le nombre d'entrées par 1 s
1 s *

// on garde une copie de la valeur en secondes
DUP
// On applique le filtre HUMANDURATION qui transforme ce volume de secondes en une durée compréhensible
HUMANDURATION

warp10 - duration 1

On voit ci-dessous l'usage de DUP avec la valeur humainement lisible, la valeur brute en seconde (puis le reste de la pile):

warp10 - duration 1

Si on ne veut pas de dataviz / ne pas conserver les valeurs intermédiaires et n'avoir que la valeur finale, on peut supprimer les lignes avec DUP et RENAME.

'<readToken>' 'readToken' STORE
[ $readToken '~crntd10monitoring.cpu.usage_idle' { 'cpu' 'cpu-total' } NOW 1 h ] FETCH
0 GET
'cpu' STORE

[
  $cpu
  bucketizer.last
  0
  1 s
  0
]
BUCKETIZE
FILLPREVIOUS
FILLNEXT

[ SWAP 90.0 mapper.ge 0 0 0 ] MAP
[ SWAP 95.0 mapper.lt 0 0 0 ] MAP
0 GET SIZE
1 s *
HUMANDURATION

Et on obtient:

20m20.000000s

Un grand merci à Mathias Herberts pour sa disponiblité, sa patience et son aide face à toutes mes questions pour arriver à produire ce code.

Warp 10 / WarpScript - version agrégée

On peut aussi vouloir avoir une version agrégée de la donnée plutôt que de filter sur un état particulier. Ainsi, on peut avoir la répartition des valeurs que prend un équipement sur un indicateur donnée.

'<readToken>' 'readToken' STORE
// Récupération des métriques comme précédemment
[ $readToken '~crntd10monitoring.cpu.usage_idle' { 'cpu' 'cpu-total' } NOW 1 h ] FETCH
0 GET
'cpu' STORE

// Reformatage des données comme précédemment
[
  $cpu
  bucketizer.last
  0
  1 s
  0
]
BUCKETIZE
FILLPREVIOUS
FILLNEXT

// Utilisation de QUANTIZE
// QUANTIZE a besoin que l'on définisse des sous-ensembles dans un ensemble
// Notre indicateur CPU étant un pourcentage, on prend par ex 10 sous ensemble compris entre 0 et 100
// QUANTIZE gère aussi les cas où l'on est plus petit que la première valeur et plus grand que la derinère valeur de l'ensemble
0 100 10 LBOUNDS
// On a donc 10+2 = 12 sous-ensembles : ]-infini,0],[1, 10],[11, 20],...,[90, 100],[101, inf+[
// Pour chaque valeur que nous allons passer à QUANTIZE, elle va retourer une valeur associée au sous ensemble dans laquelle la valeur va "tomber".
// Ainsi, un valeur de 95% va aller dans gt90.
// Liste des valeurs pour les 12 sous-ensembles :
[ 'neg' 'gt0' 'gt10' 'gt20' 'gt30' 'gt40' 'gt50' 'gt60' 'gt70' 'gt80' 'gt90' 'gt100' ]
QUANTIZE
// A ce stade, notre GTS de départ ne contient plus les valeurs de cpu mais les valeurs associées au tableau de QUANTIZE
// on passe donc de [<timestamp>, 95.45] à [<timestamp>, 'gt90']

// Utilisation de VALUEHISTOGRAM qui va compter le nombre d'occurences de chaque valeur d'une liste de GTS
VALUEHISTOGRAM

On obtient alors :

[{"gt90":3491,"gt80":40,"gt70":40,"gt60":10}]

Et voilà !

Web, Ops & Data - Octobre 2020

kubernetesingressyamlpipelinegitlabtraefikrootlessmeshyqjqdevopsdatamaturitémariadbs3flowswarp10timeseriesinfluxdbpulsaramqpmqttkafkapythongitvscodearmnvidia

Des nouvelles du Paris Time Series Meetup : l'éditions 6 sur TimescaleDB et l'édition 7 sur QuestDB

CI/CD

  • 3 YAML tips for better pipelines : la troisième est certainement la plus intéressante - il est possible d'avoir des mécanismes de "composabilité" / "héritage" avec YAML et Gitlab. Si les include et extends sont déjà sympathiques, les anchors ont l'air de faire des choses intéressantes aussi !

Code

  • What’s New In Python 3.9 et un thread twitter qui donne des exemples des principales nouveautés : au programme nouvelle syntaxe pour la fuston des dictionnaires, des méthides pour supprimer des suffixes/préfixes sur les strings, du typage et plein d'autres améliorations et corrections.
  • Fortunately, I don't squash my commits : s'il peut être tentant sur une MR/PR de faire un squash des commits, l'article vous confortera dans l'idée que ce n'est pas une bonne idée. En écrasant l'historique des commits, on y perd sur nos capacités de debug. Par ailleurs, il est conseillé de faire des petits commits pour capturer un ensemble de changements traduisant un moment précis du développement.

Container et orchestration

  • Kubernetes Ingress Goes GA : l'apparition de IngressClassName dans k8s 1.19 va plus loin qu'un simple renommage de champ comme je l'avais compris initialement. C'est une vraie ressource et cela ouvre aussi des possibilités. Avant de l'utiliser, vérifiez aussi que vos ingress controller le supporte (en plus d'attendre d'être en 1.19)
  • Houston, we have Plugins! Traefik 2.3 Announcement : la version 2.3 dont on a déjà parlé ici, est arrivé en version stable avec son support des plugins, son intégration avec Traefik Pilot, le support d'Amazone ECS et le support de la ressource IngressClassName. Au passage, Containous, la société éditrice de Traefik s'appelle maintenant Traefik Labs.
  • Introducing Traefik Pilot 1.0: the Traefik Control Center : Version 1.0 de ce nouveau "Control Plane" de Traefik qui permet d'avoir une vision globale sur ses instances traefik, d'utiliser les plugins et d'avoir un monitoring et des alertes autour de la disponibilité, des performances et de la sécurité.
  • Rootless mode : A voir si cela pourra être inclus dans la version 1.20 mais le rootless mode est clairement une tendance de fond dans kubernetes et les conteneurs en général. Si vous ne vous y êtes pas déjà mis, ne tardez pas !
  • Announcing Traefik Mesh 1.4 - New Name, New Features : nouvelle version du service mesh par Traefik Labs et qui s'appelle maintenant Traefik Mesh (et non uniquement Maesh). Le reste des améliorations semble porter sur le filtrage des headers et des paths.
  • yq : A command line tool that will help you handle your YAML resources better : vous voulez faire des opératoins sur des fichiers YAML sans faire un chart helm ou sortir kustomize, vous pouvez faire des choses minimalistes avec yq (le pendant yaml de jq).
  • Bridge to Kubernetes GA, "bridge to kubernetes" est une extension pour vscode permettant de connecter une application tournant en local avec d'autres applications situées dans un ckuster kubernetes et faciliter ainsi l'expérience des développeurs.

Culture DevOps

Data

Hardware

IaC

  • Announcing HashiCorp Terraform 0.14 Beta: la capacité à marquer des variables comme sensibles pour éviter que leur valeur soit visible dans les logs/diff/..., un diff plus concis, un lock sur les providers et des binaires disponibles pour arm64.

Monitoring

Pratique

  • endoflife.date : recense les dates de fin de support de vos langages et technologies préférées. Tout n'est pas complètement à jour mais cela permet de récupérer rapidement les informations.

SQL

  • Exciting and New Features in MariaDB 10.5 : évoqué au mois d'aout, le support de S3 dans MariaDB est disponible en version GA dans la version 10.5. D'autres améliorations existent comme le support du type INET6, des améliorations sur ColumnStore, la gestion des privilèges, le cluster Galera supporte complètement le GTID, du refactoring au niveau d'InnoDB et enfin les binaires mariadb vont enfin s'appeler mariadb et non plus mysql (avec une couche de compatibilité via des liens symboliques)

Time Series

Sur la base des informations disponibles pour le moment :

  • vous définissez une période de rétention en mémoire (entre 1h et 1 an) et une période de rétention sur stockage magnétique (1 jour à 200 ans),
  • le requêtage des données se fait en SQL (via Presto ?),
  • les données à requêter communément sont à mettre dans la même table,
  • le join est limité à la même table,
  • des mesures simples (pas de multi mesures pour un même enregistrement),
  • une intégration avec l'écosystème comme telegraf, grafana, etc en plus de l'intégration avec différents composants AWS

Pour les moins bons côtés :

  • pas d'UPDATE/DELETE sur vos données ; en cas de doublons, c'est le premier arrivé qui gagne
  • pas de bulk import de vos données, donc pas de reprise de vos données existantes. En effet, il n'est pas possible d'ingérer des données plus vieille que la période en mémoire,
  • dans la même veine, si un incident de production dépasse votre période de rétention, vous ne pourrez pas réinjecter vos données
  • il ne semble pas possible de mettre à jour ses durées de rétention - donc pas de ménage possible ou d'ajustements en cours de route

Une solution a priori très orienté pour du monitoring et qui semble souffir des mêmes travers qu'InfluxDB avec InfluxQL et pourtant en passe d'être résolus avec Flux.

On devrait en parler plus en détail dans une prochaine édition du Paris Time Series Meetup avec des personnes de chez AWS ;-)

Work

← Précédent 2 / 4 Suivant →